From e1b2ec8a4b89b1dc39b5f96a7eff8d9e0b71694e Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Fri, 9 May 2025 04:30:30 +0530 Subject: [PATCH 1/7] wip: fix to, bcc, cc handling - allow agent to set the to address, adds a to address input in the reply box. - show to, from, bcc and subject in each message - always use email addresses from message meta instead of querying via get-to-address - Reorder notification form fields. - Refactors and adhoc fixes. --- cmd/conversation.go | 20 ++-- cmd/messages.go | 6 +- frontend/src/assets/styles/main.scss | 10 +- .../notification/NotificationSettingForm.vue | 24 ++--- .../src/features/conversation/ReplyBox.vue | 91 +++++++++---------- .../features/conversation/ReplyBoxContent.vue | 43 ++++++--- .../message/AgentMessageBubble.vue | 67 ++++++++------ .../message/ContactMessageBubble.vue | 57 +++++++----- .../conversation/message/MessageEnvelope.vue | 37 ++++++++ frontend/src/stores/conversation.js | 31 +++++-- .../src/utils/conversation-message-cache.js | 23 +++++ internal/conversation/conversation.go | 14 +-- internal/conversation/message.go | 36 ++++---- internal/conversation/models/models.go | 11 ++- internal/conversation/queries.sql | 19 +--- internal/inbox/channel/email/imap.go | 41 +++++++-- 16 files changed, 323 insertions(+), 207 deletions(-) create mode 100644 frontend/src/features/conversation/message/MessageEnvelope.vue diff --git a/cmd/conversation.go b/cmd/conversation.go index a5210dd5..12a27322 100644 --- a/cmd/conversation.go +++ b/cmd/conversation.go @@ -11,6 +11,7 @@ import ( "github.com/abhinavxd/libredesk/internal/automation/models" cmodels "github.com/abhinavxd/libredesk/internal/conversation/models" "github.com/abhinavxd/libredesk/internal/envelope" + "github.com/abhinavxd/libredesk/internal/stringutil" umodels "github.com/abhinavxd/libredesk/internal/user/models" "github.com/valyala/fasthttp" "github.com/volatiletech/null/v9" @@ -640,24 +641,29 @@ func handleCreateConversation(r *fastglue.Request) error { firstName = strings.TrimSpace(string(r.RequestCtx.PostArgs().Peek("first_name"))) lastName = strings.TrimSpace(string(r.RequestCtx.PostArgs().Peek("last_name"))) subject = strings.TrimSpace(string(r.RequestCtx.PostArgs().Peek("subject"))) - content = string(r.RequestCtx.PostArgs().Peek("content")) + content = strings.TrimSpace(string(r.RequestCtx.PostArgs().Peek("content"))) + to = []string{email} ) + // Validate required fields if inboxID <= 0 { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.fieldRequired", "name", "`inbox_id`"), nil, envelope.InputError) } - if strings.TrimSpace(subject) == "" { + if subject == "" { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.fieldRequired", "name", "`subject`"), nil, envelope.InputError) } - if strings.TrimSpace(content) == "" { + if content == "" { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.fieldRequired", "name", "`content`"), nil, envelope.InputError) } - if strings.TrimSpace(email) == "" { + if email == "" { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.fieldRequired", "name", "`contact_email`"), nil, envelope.InputError) } if firstName == "" { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.fieldRequired", "name", "`first_name`"), nil, envelope.InputError) } + if !stringutil.ValidEmail(email) { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`contact_email`"), nil, envelope.InputError) + } user, err := app.user.GetAgent(auser.ID, "") if err != nil { @@ -690,8 +696,8 @@ func handleCreateConversation(r *fastglue.Request) error { contact.ID, contact.ContactChannelID, inboxID, - "", /** last_message **/ - time.Now(), + "", /** last_message **/ + time.Now(), /** last_message_at **/ subject, true, /** append reference number to subject **/ ) @@ -701,7 +707,7 @@ func handleCreateConversation(r *fastglue.Request) error { } // Send reply to the created conversation. - if err := app.conversation.SendReply(nil /**media**/, inboxID, auser.ID, conversationUUID, content, nil /**cc**/, nil /**bcc**/, map[string]any{} /**meta**/); err != nil { + if err := app.conversation.SendReply(nil /**media**/, inboxID, auser.ID, conversationUUID, content, to, nil /**cc**/, nil /**bcc**/, map[string]any{} /**meta**/); err != nil { // Delete the conversation if sending the reply fails. if err := app.conversation.DeleteConversation(conversationUUID); err != nil { app.lo.Error("error deleting conversation", "error", err) diff --git a/cmd/messages.go b/cmd/messages.go index d146f742..5f38c358 100644 --- a/cmd/messages.go +++ b/cmd/messages.go @@ -15,6 +15,7 @@ type messageReq struct { Attachments []int `json:"attachments"` Message string `json:"message"` Private bool `json:"private"` + To []string `json:"to"` CC []string `json:"cc"` BCC []string `json:"bcc"` } @@ -140,7 +141,7 @@ func handleSendMessage(r *fastglue.Request) error { return sendErrorEnvelope(r, err) } - // Check permission + // Check access to conversation. conv, err := enforceConversationAccess(app, cuuid, user) if err != nil { return sendErrorEnvelope(r, err) @@ -151,6 +152,7 @@ func handleSendMessage(r *fastglue.Request) error { return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError) } + // Prepare attachments. for _, id := range req.Attachments { m, err := app.media.Get(id, "") if err != nil { @@ -165,7 +167,7 @@ func handleSendMessage(r *fastglue.Request) error { return sendErrorEnvelope(r, err) } } else { - if err := app.conversation.SendReply(media, conv.InboxID, user.ID, cuuid, req.Message, req.CC, req.BCC, map[string]any{} /**meta**/); err != nil { + if err := app.conversation.SendReply(media, conv.InboxID, user.ID, cuuid, req.Message, req.To, req.CC, req.BCC, map[string]any{} /**meta**/); err != nil { return sendErrorEnvelope(r, err) } // Evaluate automation rules. diff --git a/frontend/src/assets/styles/main.scss b/frontend/src/assets/styles/main.scss index 90d18c2b..f021fd55 100644 --- a/frontend/src/assets/styles/main.scss +++ b/frontend/src/assets/styles/main.scss @@ -183,15 +183,7 @@ } .message-bubble { - @apply flex - flex-col - px-4 - pt-2 - pb-3 - min-w-[30%] max-w-[70%] - border - overflow-x-auto - rounded-xl; + @apply flex flex-col px-4 pt-2 pb-3 w-fit min-w-[30%] max-w-full border overflow-x-auto rounded-xl; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); table { width: 100% !important; diff --git a/frontend/src/features/admin/notification/NotificationSettingForm.vue b/frontend/src/features/admin/notification/NotificationSettingForm.vue index 645b0c5d..20d62091 100644 --- a/frontend/src/features/admin/notification/NotificationSettingForm.vue +++ b/frontend/src/features/admin/notification/NotificationSettingForm.vue @@ -97,6 +97,18 @@ + + + + {{ $t('admin.inbox.maxRetries') }} + + + + + {{ $t('admin.inbox.maxRetries.description') }} + + + @@ -136,18 +148,6 @@ - - - - {{ $t('admin.inbox.maxRetries') }} - - - - - {{ $t('admin.inbox.maxRetries.description') }} - - - diff --git a/frontend/src/features/conversation/ReplyBox.vue b/frontend/src/features/conversation/ReplyBox.vue index 98b5e06e..6bbda3c2 100644 --- a/frontend/src/features/conversation/ReplyBox.vue +++ b/frontend/src/features/conversation/ReplyBox.vue @@ -53,30 +53,20 @@ :isSending="isSending" :uploadingFiles="uploadingFiles" :clearEditorContent="clearEditorContent" - :htmlContent="htmlContent" - :textContent="textContent" - :selectedText="selectedText" - :isBold="isBold" - :isItalic="isItalic" - :cursorPosition="cursorPosition" :contentToSet="contentToSet" - :cc="cc" - :bcc="bcc" - :emailErrors="emailErrors" - :messageType="messageType" - :showBcc="showBcc" - @update:htmlContent="htmlContent = $event" - @update:textContent="textContent = $event" - @update:selectedText="selectedText = $event" - @update:isBold="isBold = $event" - @update:isItalic="isItalic = $event" - @update:cursorPosition="cursorPosition = $event" - @toggleFullscreen="isEditorFullscreen = false" - @update:messageType="messageType = $event" - @update:cc="cc = $event" - @update:bcc="bcc = $event" - @update:showBcc="showBcc = $event" - @updateEmailErrors="emailErrors = $event" + v-model:htmlContent="htmlContent" + v-model:textContent="textContent" + v-model:selectedText="selectedText" + v-model:isBold="isBold" + v-model:isItalic="isItalic" + v-model:cursorPosition="cursorPosition" + v-model:to="to" + v-model:cc="cc" + v-model:bcc="bcc" + v-model:emailErrors="emailErrors" + v-model:messageType="messageType" + v-model:showBcc="showBcc" + @toggleFullscreen="isEditorFullscreen = true" @send="processSend" @fileUpload="handleFileUpload" @inlineImageUpload="handleInlineImageUpload" @@ -99,30 +89,20 @@ :isSending="isSending" :uploadingFiles="uploadingFiles" :clearEditorContent="clearEditorContent" - :htmlContent="htmlContent" - :textContent="textContent" - :selectedText="selectedText" - :isBold="isBold" - :isItalic="isItalic" - :cursorPosition="cursorPosition" :contentToSet="contentToSet" - :cc="cc" - :bcc="bcc" - :emailErrors="emailErrors" - :messageType="messageType" - :showBcc="showBcc" - @update:htmlContent="htmlContent = $event" - @update:textContent="textContent = $event" - @update:selectedText="selectedText = $event" - @update:isBold="isBold = $event" - @update:isItalic="isItalic = $event" - @update:cursorPosition="cursorPosition = $event" + v-model:htmlContent="htmlContent" + v-model:textContent="textContent" + v-model:selectedText="selectedText" + v-model:isBold="isBold" + v-model:isItalic="isItalic" + v-model:cursorPosition="cursorPosition" + v-model:to="to" + v-model:cc="cc" + v-model:bcc="bcc" + v-model:emailErrors="emailErrors" + v-model:messageType="messageType" + v-model:showBcc="showBcc" @toggleFullscreen="isEditorFullscreen = true" - @update:messageType="messageType = $event" - @update:cc="cc = $event" - @update:bcc="bcc = $event" - @update:showBcc="showBcc = $event" - @updateEmailErrors="emailErrors = $event" @send="processSend" @fileUpload="handleFileUpload" @inlineImageUpload="handleInlineImageUpload" @@ -183,6 +163,7 @@ const clearEditorContent = ref(false) const isEditorFullscreen = ref(false) const isSending = ref(false) const messageType = ref('reply') +const to = ref('') const cc = ref('') const bcc = ref('') const showBcc = ref(false) @@ -353,6 +334,7 @@ const processSend = async () => { .map((img) => img.getAttribute('title')) .filter(Boolean) + // TODO: Inline images are not supported yet, this is some old boilerplate code. conversationStore.conversation.mediaFiles = conversationStore.conversation.mediaFiles.filter( (file) => // Keep if: @@ -375,12 +357,18 @@ const processSend = async () => { .split(',') .map((email) => email.trim()) .filter((email) => email) + : [], + to: to.value + ? to.value + .split(',') + .map((email) => email.trim()) + .filter((email) => email) : [] }) } // Apply macro actions if any. - // For macros errors just show toast and clear the editor, as most likely it's the permission error. + // For macro errors just show toast and clear the editor. if (conversationStore.conversation?.macro?.actions?.length > 0) { try { await api.applyMacro( @@ -390,7 +378,6 @@ const processSend = async () => { ) } catch (error) { emitter.emit(EMITTER_EVENTS.SHOW_TOAST, { - title: 'Error', variant: 'destructive', description: handleHTTPError(error).message }) @@ -453,7 +440,7 @@ watch( { deep: true } ) -// Initialize cc and bcc from conversation store +// Initialize to, cc, and bcc fields with the current conversation's values. watch( () => conversationStore.currentCC, (newVal) => { @@ -462,6 +449,14 @@ watch( { deep: true, immediate: true } ) +watch( + () => conversationStore.currentTo, + (newVal) => { + to.value = newVal?.join(', ') || '' + }, + { immediate: true } +) + watch( () => conversationStore.currentBCC, (newVal) => { diff --git a/frontend/src/features/conversation/ReplyBoxContent.vue b/frontend/src/features/conversation/ReplyBoxContent.vue index ada843da..585bf663 100644 --- a/frontend/src/features/conversation/ReplyBoxContent.vue +++ b/frontend/src/features/conversation/ReplyBoxContent.vue @@ -37,11 +37,21 @@ - +
+
+ + +
- +
{ }) /** - * Validate email addresses in the CC and BCC fields - * @param {string} field - 'cc' or 'bcc' + * Validate email addresses in the To, CC, and BCC fields + * @param {string} field - 'to', 'cc', or 'bcc' */ const validateEmails = (field) => { - const emails = field === 'cc' ? cc.value : bcc.value + console.log('Validating emails for field:', field) + const emails = field === 'to' ? to.value : field === 'cc' ? cc.value : bcc.value const emailList = emails .split(',') .map((e) => e.trim()) .filter((e) => e !== '') - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ - const invalidEmails = emailList.filter((email) => !emailRegex.test(email)) - // Remove any existing errors for this field - emailErrors.value = emailErrors.value.filter( - (error) => !error.startsWith(`${t('replyBox.invalidEmailsIn')} ${field.toUpperCase()}`) - ) + const invalidEmails = emailList.filter((email) => !validateEmail(email)) + + console.log('Invalid emails:', invalidEmails) + + // Clear existing errors + emailErrors.value = [] // Add new error if there are invalid emails if (invalidEmails.length > 0) { - emailErrors.value.push( - `${t('replyBox.invalidEmailsIn')} ${field.toUpperCase()}: ${invalidEmails.join(', ')}` - ) + emailErrors.value = [ + ...emailErrors.value, + `${t('replyBox.invalidEmailsIn')} '${field}': ${invalidEmails.join(', ')}` + ] } } @@ -272,6 +286,7 @@ const validateEmails = (field) => { * Send the reply or private note */ const handleSend = async () => { + validateEmails('to') validateEmails('cc') validateEmails('bcc') if (emailErrors.value.length > 0) { diff --git a/frontend/src/features/conversation/message/AgentMessageBubble.vue b/frontend/src/features/conversation/message/AgentMessageBubble.vue index 162f078e..c17aa1fa 100644 --- a/frontend/src/features/conversation/message/AgentMessageBubble.vue +++ b/frontend/src/features/conversation/message/AgentMessageBubble.vue @@ -9,38 +9,46 @@
-
- + +
+ class="flex flex-col justify-end message-bubble relative" + :class="{ + '!bg-[#FEF1E1]': message.private, + 'bg-white border border-border': !message.private, + 'opacity-50 animate-pulse': message.status === 'pending', + 'bg-red-50 border-red-200': message.status === 'failed' + }" + > + + - - +
- - - - -
- - - +
+ + + + + + + + +
+ + + +
@@ -53,7 +61,7 @@
- +
@@ -79,6 +87,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip import { Spinner } from '@/components/ui/spinner' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import MessageAttachmentPreview from '@/features/conversation/message/attachment/MessageAttachmentPreview.vue' +import MessageEnvelope from './MessageEnvelope.vue' import api from '@/api' const props = defineProps({ diff --git a/frontend/src/features/conversation/message/ContactMessageBubble.vue b/frontend/src/features/conversation/message/ContactMessageBubble.vue index 64c5be34..b8b49e09 100644 --- a/frontend/src/features/conversation/message/ContactMessageBubble.vue +++ b/frontend/src/features/conversation/message/ContactMessageBubble.vue @@ -8,7 +8,7 @@
-
+
@@ -18,32 +18,40 @@ -
- - - - +
- {{ showQuotedText ? t('conversation.hideQuotedText') : t('conversation.showQuotedText') }} -
+ - - +
+ + + + + +
+ {{ + showQuotedText ? t('conversation.hideQuotedText') : t('conversation.showQuotedText') + }} +
+ + + +
@@ -75,6 +83,7 @@ import { Letter } from 'vue-letter' import { useAppSettingsStore } from '@/stores/appSettings' import { useI18n } from 'vue-i18n' import MessageAttachmentPreview from '@/features/conversation/message/attachment/MessageAttachmentPreview.vue' +import MessageEnvelope from './MessageEnvelope.vue' const props = defineProps({ message: Object diff --git a/frontend/src/features/conversation/message/MessageEnvelope.vue b/frontend/src/features/conversation/message/MessageEnvelope.vue new file mode 100644 index 00000000..d3c33500 --- /dev/null +++ b/frontend/src/features/conversation/message/MessageEnvelope.vue @@ -0,0 +1,37 @@ + + + diff --git a/frontend/src/stores/conversation.js b/frontend/src/stores/conversation.js index 15d84e3e..26a99400 100644 --- a/frontend/src/stores/conversation.js +++ b/frontend/src/stores/conversation.js @@ -1,5 +1,5 @@ import { defineStore } from 'pinia' -import { computed, reactive, ref, nextTick } from 'vue' +import { computed, reactive, ref, nextTick, watchEffect } from 'vue' import { CONVERSATION_LIST_TYPE, CONVERSATION_DEFAULT_STATUSES } from '@/constants/conversation' import { handleHTTPError } from '@/utils/http' import { useEmitter } from '@/composables/useEmitter' @@ -12,6 +12,9 @@ export const useConversationStore = defineStore('conversation', () => { const MESSAGE_LIST_PAGE_SIZE = 30 const priorities = ref([]) const statuses = ref([]) + const currentTo = ref([]) + const currentBCC = ref([]) + const currentCC = ref([]) // Options for select fields const priorityOptions = computed(() => { @@ -245,12 +248,27 @@ export const useConversationStore = defineStore('conversation', () => { return Object.keys(conversation.data || {}).length > 0 }) - const currentBCC = computed(() => { - return conversation.data?.bcc || [] - }) + // Watch for changes in the conversation and messages and update the to, cc, and bcc fields accordingly + watchEffect(() => { + const conv = conversation.data + const msgData = messages.data + if (!conv || !msgData) return + const latestMessage = msgData.getLatestMessage(conv.uuid, ['incoming', 'outgoing'], true) + if (!latestMessage) return + const meta = latestMessage?.meta || {} + currentCC.value = meta.cc || [] + currentBCC.value = meta.bcc || [] + // Set correct "To" field based on message type + if (latestMessage.type === 'incoming') { + currentTo.value = meta.from || [] + } else { + currentTo.value = meta.to || [] + } - const currentCC = computed(() => { - return conversation.data?.cc || [] + // If to is still empty, set it to the contact's email + if (currentTo.value.length === 0 && conv.contact) { + currentTo.value = [conv.contact.email] + } }) async function fetchParticipants (uuid) { @@ -647,6 +665,7 @@ export const useConversationStore = defineStore('conversation', () => { hasConversationOpen, current, currentContactName, + currentTo, currentBCC, currentCC, clearListReRenderInterval, diff --git a/frontend/src/utils/conversation-message-cache.js b/frontend/src/utils/conversation-message-cache.js index 9416843a..8d3b0065 100644 --- a/frontend/src/utils/conversation-message-cache.js +++ b/frontend/src/utils/conversation-message-cache.js @@ -69,6 +69,29 @@ export default class MessageCache { .sort((a, b) => new Date(a.created_at) - new Date(b.created_at)) } + /** + * Returns latest message for a conversation + * @param {string} convId - Conversation ID + * @param {string[]} type - Array of message types to filter - outgoing, incoming, etc. + * @param {boolean} excludePrivate - Exclude private messages + * + * @returns {object} - Latest message object or null if not found + */ + getLatestMessage (convId, type = [], excludePrivate = false) { + const conv = this.cache.get(convId) + if (!conv) return null + let allMessages = Array.from(conv.pages.values()).flat() + // If type is provided, filter messages by type + if (type.length > 0) { + allMessages = allMessages.filter(msg => type.includes(msg.type)) + } + // If excludePrivate is true, filter out private messages + if (excludePrivate) { + allMessages = allMessages.filter(msg => !msg.private) + } + return allMessages.length ? allMessages[0] : null + } + /** * Updates message fields by applying update object */ diff --git a/internal/conversation/conversation.go b/internal/conversation/conversation.go index 0360f700..810acf48 100644 --- a/internal/conversation/conversation.go +++ b/internal/conversation/conversation.go @@ -41,11 +41,11 @@ import ( var ( //go:embed queries.sql - efs embed.FS - errConversationNotFound = errors.New("conversation not found") - conversationsAllowedFields = []string{"status_id", "priority_id", "assigned_team_id", "assigned_user_id", "inbox_id", "last_message_at", "created_at", "waiting_since", "next_sla_deadline_at", "priority_id"} - conversationStatusAllowedFields = []string{"id", "name"} - csatReplyMessage = "Please rate your experience with us: Rate now" + efs embed.FS + errConversationNotFound = errors.New("conversation not found") + conversationsAllowedFields = []string{"status_id", "priority_id", "assigned_team_id", "assigned_user_id", "inbox_id", "last_message_at", "created_at", "waiting_since", "next_sla_deadline_at", "priority_id"} + conversationStatusAllowedFields = []string{"id", "name"} + csatReplyMessage = "Please rate your experience with us: Rate now" ) const ( @@ -886,7 +886,7 @@ func (m *Manager) ApplyAction(action amodels.RuleAction, conv models.Conversatio case amodels.ActionSendPrivateNote: return m.SendPrivateNote([]mmodels.Media{}, user.ID, conv.UUID, action.Value[0]) case amodels.ActionReply: - return m.SendReply([]mmodels.Media{}, conv.InboxID, user.ID, conv.UUID, action.Value[0], nil, nil, nil) + return m.SendReply([]mmodels.Media{}, conv.InboxID, user.ID, conv.UUID, action.Value[0], nil, nil, nil, nil) case amodels.ActionSetSLA: slaID, _ := strconv.Atoi(action.Value[0]) return m.ApplySLA(conv, slaID, user) @@ -924,7 +924,7 @@ func (m *Manager) SendCSATReply(actorUserID int, conversation models.Conversatio meta := map[string]interface{}{ "is_csat": true, } - return m.SendReply([]mmodels.Media{}, conversation.InboxID, actorUserID, conversation.UUID, message, nil, nil, meta) + return m.SendReply([]mmodels.Media{}, conversation.InboxID, actorUserID, conversation.UUID, message, nil, nil, nil, meta) } // DeleteConversation deletes a conversation. diff --git a/internal/conversation/message.go b/internal/conversation/message.go index 48f1fcce..b8fb24f3 100644 --- a/internal/conversation/message.go +++ b/internal/conversation/message.go @@ -142,7 +142,7 @@ func (m *Manager) sendOutgoingMessage(message models.Message) { return } - // Render content template + // Render content in template if err := m.RenderContentInTemplate(inbox.Channel(), &message); err != nil { handleError(err, "error rendering content in template") return @@ -154,12 +154,8 @@ func (m *Manager) sendOutgoingMessage(message models.Message) { return } - // Set from and to addresses + // Set from address of the inbox message.From = inbox.FromAddress() - message.To, err = m.GetToAddress(message.ConversationID) - if handleError(err, "error fetching `to` address") { - return - } // Set "In-Reply-To" and "References" headers, logging any errors but continuing to send the message. // Include only the last 20 messages as references to avoid exceeding header size limits. @@ -318,16 +314,24 @@ func (m *Manager) SendPrivateNote(media []mmodels.Media, senderID int, conversat } // SendReply inserts a reply message in a conversation. -func (m *Manager) SendReply(media []mmodels.Media, inboxID, senderID int, conversationUUID, content string, cc, bcc []string, meta map[string]interface{}) error { - // Save cc and bcc as JSON in meta. +func (m *Manager) SendReply(media []mmodels.Media, inboxID, senderID int, conversationUUID, content string, to, cc, bcc []string, meta map[string]interface{}) error { + // Save to, cc and bcc in meta. + to = stringutil.RemoveEmpty(to) cc = stringutil.RemoveEmpty(cc) bcc = stringutil.RemoveEmpty(bcc) + + if len(to) == 0 { + return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.empty", "name", "to"), nil) + } + meta["to"] = to + if len(cc) > 0 { meta["cc"] = cc } if len(bcc) > 0 { meta["bcc"] = bcc } + metaJSON, err := json.Marshal(meta) if err != nil { return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorMarshalling", "name", "{globals.terms.meta}"), nil) @@ -355,7 +359,7 @@ func (m *Manager) SendReply(media []mmodels.Media, inboxID, senderID int, conver ContentType: models.ContentTypeHTML, Private: false, Media: media, - Meta: string(metaJSON), + Meta: metaJSON, SourceID: null.StringFrom(sourceID), } return m.InsertMessage(&message) @@ -368,9 +372,8 @@ func (m *Manager) InsertMessage(message *models.Message) error { message.Status = models.MessageStatusSent } - // Handle empty meta. - if message.Meta == "" || message.Meta == "null" { - message.Meta = "{}" + if len(message.Meta) == 0 || string(message.Meta) == "null" { + message.Meta = json.RawMessage(`{}`) } // Convert HTML content to text for search. @@ -566,11 +569,10 @@ func (m *Manager) processIncomingMessage(in models.IncomingMessage) error { systemUser, err := m.userStore.GetSystemUser() if err != nil { m.lo.Error("error fetching system user", "error", err) - return fmt.Errorf("error fetching system user for reopening conversation: %w", err) - } - if err := m.ReOpenConversation(in.Message.ConversationUUID, systemUser); err != nil { - m.lo.Error("error reopening conversation", "error", err) - return fmt.Errorf("error reopening conversation: %w", err) + } else { + if err := m.ReOpenConversation(in.Message.ConversationUUID, systemUser); err != nil { + m.lo.Error("error reopening conversation", "error", err) + } } // Trigger automations on incoming message event. diff --git a/internal/conversation/models/models.go b/internal/conversation/models/models.go index bd331f0f..b9cf528c 100644 --- a/internal/conversation/models/models.go +++ b/internal/conversation/models/models.go @@ -74,8 +74,8 @@ type Conversation struct { WaitingSince null.Time `db:"waiting_since" json:"waiting_since"` Subject null.String `db:"subject" json:"subject"` UnreadMessageCount int `db:"unread_message_count" json:"unread_message_count"` - InboxName string `db:"inbox_name" json:"inbox_name"` - InboxChannel string `db:"inbox_channel" json:"inbox_channel"` + InboxName string `db:"inbox_name" json:"inbox_name,omitempty"` + InboxChannel string `db:"inbox_channel" json:"inbox_channel,omitempty"` Tags null.JSON `db:"tags" json:"tags"` Meta pq.StringArray `db:"meta" json:"meta"` CustomAttributes json.RawMessage `db:"custom_attributes" json:"custom_attributes"` @@ -89,6 +89,7 @@ type Conversation struct { FirstResponseDueAt null.Time `db:"first_response_deadline_at" json:"first_response_deadline_at"` ResolutionDueAt null.Time `db:"resolution_deadline_at" json:"resolution_deadline_at"` SLAStatus null.String `db:"sla_status" json:"sla_status"` + To json.RawMessage `db:"to" json:"to"` BCC json.RawMessage `db:"bcc" json:"bcc"` CC json.RawMessage `db:"cc" json:"cc"` PreviousConversations []Conversation `db:"-" json:"previous_conversations"` @@ -131,19 +132,19 @@ type Message struct { SenderID int `db:"sender_id" json:"sender_id"` SenderType string `db:"sender_type" json:"sender_type"` InboxID int `db:"inbox_id" json:"-"` - Meta string `db:"meta" json:"meta"` + Meta json.RawMessage `db:"meta" json:"meta"` Attachments attachment.Attachments `db:"attachments" json:"attachments"` ConversationUUID string `db:"conversation_uuid" json:"-"` From string `db:"from" json:"-"` - To []string `db:"from" json:"-"` - AltContent string `db:"alt_content" json:"-"` Subject string `db:"subject" json:"-"` Channel string `db:"channel" json:"-"` + To pq.StringArray `db:"to" json:"-"` CC pq.StringArray `db:"cc" json:"-"` BCC pq.StringArray `db:"bcc" json:"-"` References []string `json:"-"` InReplyTo string `json:"-"` Headers textproto.MIMEHeader `json:"-"` + AltContent string `db:"-" json:"-"` Media []mmodels.Media `db:"-" json:"-"` IsCSAT bool `db:"-" json:"-"` Total int `db:"total" json:"-"` diff --git a/internal/conversation/queries.sql b/internal/conversation/queries.sql index 3066f6ed..c0c06f74 100644 --- a/internal/conversation/queries.sql +++ b/internal/conversation/queries.sql @@ -82,21 +82,6 @@ SELECT WHERE 1=1 %s -- name: get-conversation -WITH last_reply AS ( - SELECT - conversation_id, - meta->'cc' as cc, - meta->'bcc' as bcc - FROM conversation_messages - WHERE private = false - AND type IN ('outgoing', 'incoming') - AND ( - ($1 > 0 AND conversation_id = $1) - OR ($2 != '' AND conversation_id = (SELECT id FROM conversations WHERE uuid = $2::uuid)) - ) - ORDER BY created_at DESC - LIMIT 1 -) SELECT c.id, c.created_at, @@ -138,8 +123,6 @@ SELECT ct.phone_number as "contact.phone_number", ct.phone_number_calling_code as "contact.phone_number_calling_code", ct.custom_attributes as "contact.custom_attributes", - COALESCE(lr.cc, '[]'::jsonb) as cc, - COALESCE(lr.bcc, '[]'::jsonb) as bcc, as_latest.first_response_deadline_at, as_latest.resolution_deadline_at, as_latest.status as sla_status @@ -149,7 +132,6 @@ LEFT JOIN sla_policies sla ON c.sla_policy_id = sla.id LEFT JOIN teams at ON at.id = c.assigned_team_id LEFT JOIN conversation_statuses s ON c.status_id = s.id LEFT JOIN conversation_priorities p ON c.priority_id = p.id -LEFT JOIN last_reply lr ON lr.conversation_id = c.id LEFT JOIN LATERAL ( SELECT first_response_deadline_at, resolution_deadline_at, status FROM applied_slas @@ -424,6 +406,7 @@ SELECT m.source_id, ARRAY(SELECT jsonb_array_elements_text(m.meta->'cc')) AS cc, ARRAY(SELECT jsonb_array_elements_text(m.meta->'bcc')) AS bcc, + ARRAY(SELECT jsonb_array_elements_text(m.meta->'to')) AS to, c.inbox_id, c.uuid as conversation_uuid, c.subject diff --git a/internal/inbox/channel/email/imap.go b/internal/inbox/channel/email/imap.go index 85df6da0..34fe9aa6 100644 --- a/internal/inbox/channel/email/imap.go +++ b/internal/inbox/channel/email/imap.go @@ -186,7 +186,7 @@ func (e *Email) processEnvelope(ctx context.Context, client *imapclient.Client, e.lo.Warn("no sender received for email", "message_id", env.MessageID) return nil } - var fromAddr = env.From[0].Addr() + var fromAddress = env.From[0].Addr() // Check if the message already exists in the database. // If it does, ignore it. @@ -200,14 +200,14 @@ func (e *Email) processEnvelope(ctx context.Context, client *imapclient.Client, } // Check if contact with this email is blocked / disabed, if so, ignore the message. - if contact, err := e.userStore.GetContact(0, fromAddr); err != nil { + if contact, err := e.userStore.GetContact(0, fromAddress); err != nil { envErr, ok := err.(envelope.Error) if !ok || envErr.ErrorType != envelope.NotFoundError { - e.lo.Error("error checking if user is blocked", "email", fromAddr, "error", err) + e.lo.Error("error checking if user is blocked", "email", fromAddress, "error", err) return fmt.Errorf("checking if user is blocked: %w", err) } } else if !contact.Enabled { - e.lo.Debug("contact is blocked, ignoring message", "email", fromAddr) + e.lo.Debug("contact is blocked, ignoring message", "email", fromAddress) return nil } @@ -220,20 +220,43 @@ func (e *Email) processEnvelope(ctx context.Context, client *imapclient.Client, FirstName: firstName, LastName: lastName, SourceChannel: null.NewString(e.Channel(), true), - SourceChannelID: null.NewString(fromAddr, true), - Email: null.NewString(fromAddr, true), + SourceChannelID: null.NewString(fromAddress, true), + Email: null.NewString(fromAddress, true), Type: umodels.UserTypeContact, } - // Set CC addresses in meta. + // Set `to`, `cc`, and `bcc` addresses in meta. var ccAddr = make([]string, 0, len(env.Cc)) + var toAddr = make([]string, 0, len(env.To)) + var bccAddr = make([]string, 0, len(env.Bcc)) + var fromAddr = make([]string, 0, len(env.From)) for _, cc := range env.Cc { if cc.Addr() != "" { ccAddr = append(ccAddr, cc.Addr()) } } + for _, to := range env.To { + if to.Addr() != "" { + toAddr = append(toAddr, to.Addr()) + } + } + for _, bcc := range env.Bcc { + if bcc.Addr() != "" { + bccAddr = append(bccAddr, bcc.Addr()) + } + } + for _, from := range env.From { + if from.Addr() != "" { + fromAddr = append(fromAddr, from.Addr()) + } + } + meta, err := json.Marshal(map[string]interface{}{ - "cc": ccAddr, + "from": fromAddr, + "cc": ccAddr, + "bcc": bccAddr, + "to": toAddr, + "subject": env.Subject, }) if err != nil { e.lo.Error("error marshalling meta", "error", err) @@ -248,7 +271,7 @@ func (e *Email) processEnvelope(ctx context.Context, client *imapclient.Client, Status: models.MessageStatusReceived, Subject: env.Subject, SourceID: null.StringFrom(env.MessageID), - Meta: string(meta), + Meta: meta, }, Contact: contact, InboxID: inboxID, From 6b2be5704942f1c43673d5853561d611a51db804 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Sat, 10 May 2025 23:46:19 +0530 Subject: [PATCH 2/7] fix: set correct recipients when a 3rd email is involved in conversation, link to thread discussing this - https://github.com/abhinavxd/libredesk/issues/74#issue-3021419913 refactor move recipient computation to /utils/email-recipients --- .../message/AgentMessageBubble.vue | 14 +++++- .../message/ContactMessageBubble.vue | 14 +++++- .../conversation/message/MessageEnvelope.vue | 11 +---- frontend/src/stores/conversation.js | 47 ++++++++++++------- .../src/utils/conversation-message-cache.js | 15 ++++-- frontend/src/utils/email-recipients.js | 44 +++++++++++++++++ internal/conversation/conversation.go | 9 ++++ internal/conversation/models/models.go | 1 + internal/conversation/queries.sql | 2 + internal/stringutil/stringutil.go | 9 ++++ 10 files changed, 129 insertions(+), 37 deletions(-) create mode 100644 frontend/src/utils/email-recipients.js diff --git a/frontend/src/features/conversation/message/AgentMessageBubble.vue b/frontend/src/features/conversation/message/AgentMessageBubble.vue index c17aa1fa..5191fe22 100644 --- a/frontend/src/features/conversation/message/AgentMessageBubble.vue +++ b/frontend/src/features/conversation/message/AgentMessageBubble.vue @@ -21,9 +21,9 @@ }" > - + -
+
{ const retryMessage = (msg) => { api.retryMessage(convStore.current.uuid, msg.uuid) } + +const showEnvelope = computed(() => { + return ( + props.message.meta?.from?.length || + props.message.meta?.to?.length || + props.message.meta?.cc?.length || + props.message.meta?.bcc?.length || + props.message.meta?.subject + ) +})