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:
Abhinav Raut
2026-05-15 16:24:25 +05:30
committed by GitHub
8 changed files with 29 additions and 306 deletions
@@ -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))
+20 -23
View File
@@ -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)
-13
View File
@@ -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.
-20
View File
@@ -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;
-33
View File
@@ -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
}
-72
View File
@@ -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 {
-142
View File
@@ -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 {