diff --git a/cmd/conversation.go b/cmd/conversation.go index a5210dd5..d8c7d4af 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,8 +707,8 @@ 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 { - // Delete the conversation if sending the reply fails. + if err := app.conversation.SendReply(nil /**media**/, inboxID, auser.ID /**sender_id**/, conversationUUID, content, to, nil /**cc**/, nil /**bcc**/, map[string]any{} /**meta**/); err != nil { + // Delete the conversation if 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..cf411539 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 + 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)) + + // 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 +283,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..5191fe22 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({ @@ -128,6 +137,16 @@ const avatarFallback = computed(() => { 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 + ) +})