mirror of
https://github.com/abhinavxd/libredesk.git
synced 2026-09-23 19:06:35 +00:00
Merge pull request #85 from abhinavxd/fix/email-channel-to-bcc-cc
Fix and Improve Email Recipients Handling in Conversations
This commit is contained in:
+14
-8
@@ -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)
|
||||
}
|
||||
|
||||
+4
-2
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -97,6 +97,18 @@
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<!-- Max Message Retries Field -->
|
||||
<FormField v-slot="{ componentField }" name="max_msg_retries">
|
||||
<FormItem>
|
||||
<FormLabel>{{ $t('admin.inbox.maxRetries') }}</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" placeholder="3" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription> {{ $t('admin.inbox.maxRetries.description') }} </FormDescription>
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<!-- Authentication Protocol Field -->
|
||||
<FormField v-slot="{ componentField }" name="auth_protocol">
|
||||
<FormItem>
|
||||
@@ -136,18 +148,6 @@
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<!-- Max Message Retries Field -->
|
||||
<FormField v-slot="{ componentField }" name="max_msg_retries">
|
||||
<FormItem>
|
||||
<FormLabel>{{ $t('admin.inbox.maxRetries') }}</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" placeholder="3" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription> {{ $t('admin.inbox.maxRetries.description') }} </FormDescription>
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<!-- HELO Hostname Field -->
|
||||
<FormField v-slot="{ componentField }" name="hello_hostname">
|
||||
<FormItem>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -37,11 +37,21 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- CC and BCC fields -->
|
||||
<!-- To, CC, and BCC fields -->
|
||||
<div
|
||||
:class="['space-y-3', isFullscreen ? 'p-4 border-b border-border' : 'mb-4']"
|
||||
v-if="messageType === 'reply'"
|
||||
>
|
||||
<div class="flex items-center space-x-2">
|
||||
<label class="w-12 text-sm font-medium text-muted-foreground">To:</label>
|
||||
<Input
|
||||
type="text"
|
||||
:placeholder="t('replyBox.emailAddresess')"
|
||||
v-model="to"
|
||||
class="flex-grow px-3 py-2 text-sm border rounded-md focus:ring-2 focus:ring-ring"
|
||||
@blur="validateEmails('to')"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<label class="w-12 text-sm font-medium text-muted-foreground">CC:</label>
|
||||
<Input
|
||||
@@ -71,7 +81,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CC and BCC field validation errors -->
|
||||
<!-- email errors -->
|
||||
<div
|
||||
v-if="emailErrors.length > 0"
|
||||
class="mb-4 px-2 py-1 bg-destructive/10 border border-destructive text-destructive rounded"
|
||||
@@ -148,9 +158,11 @@ import AttachmentsPreview from '@/features/conversation/message/attachment/Attac
|
||||
import MacroActionsPreview from '@/features/conversation/MacroActionsPreview.vue'
|
||||
import ReplyBoxMenuBar from '@/features/conversation/ReplyBoxMenuBar.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { validateEmail } from '@/utils/strings'
|
||||
|
||||
// Define models for two-way binding
|
||||
const messageType = defineModel('messageType', { default: 'reply' })
|
||||
const to = defineModel('to', { default: '' })
|
||||
const cc = defineModel('cc', { default: '' })
|
||||
const bcc = defineModel('bcc', { default: '' })
|
||||
const showBcc = defineModel('showBcc', { default: false })
|
||||
@@ -243,28 +255,27 @@ const enableSend = computed(() => {
|
||||
})
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
|
||||
@@ -9,38 +9,46 @@
|
||||
|
||||
<!-- Message Bubble -->
|
||||
<div class="flex flex-row gap-2 justify-end">
|
||||
<div
|
||||
class="flex flex-col message-bubble justify-end items-end relative rounded-lg p-3 max-w-[80%]"
|
||||
: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'
|
||||
}"
|
||||
>
|
||||
<!-- Message Content -->
|
||||
<!-- Bubble Wrapper with max 80% width -->
|
||||
<div class="w-4/5 flex justify-end">
|
||||
<div
|
||||
v-dompurify-html="messageContent"
|
||||
class="whitespace-pre-wrap break-words overflow-wrap-anywhere native-html"
|
||||
:class="{ 'mb-3': message.attachments.length > 0 }"
|
||||
/>
|
||||
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'
|
||||
}"
|
||||
>
|
||||
<!-- Message Envelope -->
|
||||
<MessageEnvelope :message="message" v-if="showEnvelope" />
|
||||
|
||||
<!-- Attachments -->
|
||||
<MessageAttachmentPreview :attachments="nonInlineAttachments" />
|
||||
<hr class="mb-2" v-if="showEnvelope" />
|
||||
|
||||
<!-- Spinner for Pending Messages -->
|
||||
<Spinner v-if="message.status === 'pending'" size="w-4 h-4" />
|
||||
|
||||
<!-- Icons -->
|
||||
<div class="flex items-center space-x-2 mt-2">
|
||||
<Lock :size="10" v-if="isPrivateMessage" class="text-muted-foreground" />
|
||||
<Check :size="14" v-if="showCheckCheck" class="text-green-500" />
|
||||
<RotateCcw
|
||||
size="10"
|
||||
@click="retryMessage(message)"
|
||||
class="cursor-pointer text-muted-foreground hover:text-foreground transition-colors duration-200"
|
||||
v-if="showRetry"
|
||||
<!-- Message -->
|
||||
<div
|
||||
v-dompurify-html="messageContent"
|
||||
class="whitespace-pre-wrap break-words overflow-wrap-anywhere native-html"
|
||||
:class="{ 'mb-3': message.attachments.length > 0 }"
|
||||
/>
|
||||
|
||||
<!-- Attachments -->
|
||||
<MessageAttachmentPreview :attachments="nonInlineAttachments" />
|
||||
|
||||
<!-- Spinner for Pending Messages -->
|
||||
<Spinner v-if="message.status === 'pending'" size="w-4 h-4" />
|
||||
|
||||
<!-- Icons -->
|
||||
<div class="flex items-center space-x-2 mt-2 self-end">
|
||||
<Lock :size="10" v-if="isPrivateMessage" class="text-muted-foreground" />
|
||||
<Check :size="14" v-if="showCheckCheck" class="text-green-500" />
|
||||
<RotateCcw
|
||||
size="10"
|
||||
@click="retryMessage(message)"
|
||||
class="cursor-pointer text-muted-foreground hover:text-foreground transition-colors duration-200"
|
||||
v-if="showRetry"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -53,7 +61,7 @@
|
||||
</Avatar>
|
||||
</div>
|
||||
|
||||
<!-- Timestamp -->
|
||||
<!-- Timestamp tooltip -->
|
||||
<div class="pr-[47px]">
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Message Bubble -->
|
||||
<div class="flex flex-row gap-2">
|
||||
<div class="flex flex-row gap-2 w-full">
|
||||
<!-- Avatar -->
|
||||
<Avatar class="cursor-pointer w-8 h-8">
|
||||
<AvatarImage :src="getAvatar" />
|
||||
@@ -18,32 +18,40 @@
|
||||
</Avatar>
|
||||
|
||||
<!-- Message Content -->
|
||||
<div
|
||||
class="flex flex-col justify-end message-bubble bg-white border border-border rounded-lg p-3 max-w-[80%]"
|
||||
:class="{
|
||||
'show-quoted-text': showQuotedText,
|
||||
'hide-quoted-text': !showQuotedText
|
||||
}"
|
||||
>
|
||||
<!-- Message Text -->
|
||||
<Letter
|
||||
:html="sanitizedMessageContent"
|
||||
:allowedSchemas="['cid', 'https', 'http', 'mailto']"
|
||||
class="mb-1 native-html"
|
||||
:class="{ 'mb-3': message.attachments.length > 0 }"
|
||||
/>
|
||||
|
||||
<!-- Quoted Text Toggle -->
|
||||
<div class="w-4/5">
|
||||
<div
|
||||
v-if="hasQuotedContent"
|
||||
@click="toggleQuote"
|
||||
class="text-xs cursor-pointer text-muted-foreground px-2 py-1 w-max hover:bg-muted hover:text-primary rounded-md transition-all"
|
||||
class="flex flex-col justify-end message-bubble"
|
||||
:class="{
|
||||
'show-quoted-text': showQuotedText,
|
||||
'hide-quoted-text': !showQuotedText
|
||||
}"
|
||||
>
|
||||
{{ showQuotedText ? t('conversation.hideQuotedText') : t('conversation.showQuotedText') }}
|
||||
</div>
|
||||
<MessageEnvelope :message="message" v-if="showEnvelope" />
|
||||
|
||||
<!-- Attachments -->
|
||||
<MessageAttachmentPreview :attachments="nonInlineAttachments" />
|
||||
<hr class="mb-2" v-if="showEnvelope" />
|
||||
|
||||
<!-- Message Text -->
|
||||
<Letter
|
||||
:html="sanitizedMessageContent"
|
||||
:allowedSchemas="['cid', 'https', 'http', 'mailto']"
|
||||
class="mb-1 native-html"
|
||||
:class="{ 'mb-3': message.attachments.length > 0 }"
|
||||
/>
|
||||
|
||||
<!-- Quoted Text Toggle -->
|
||||
<div
|
||||
v-if="hasQuotedContent"
|
||||
@click="toggleQuote"
|
||||
class="text-xs cursor-pointer text-muted-foreground px-2 py-1 w-max hover:bg-muted hover:text-primary rounded-md transition-all"
|
||||
>
|
||||
{{
|
||||
showQuotedText ? t('conversation.hideQuotedText') : t('conversation.showQuotedText')
|
||||
}}
|
||||
</div>
|
||||
|
||||
<!-- Attachments -->
|
||||
<MessageAttachmentPreview :attachments="nonInlineAttachments" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
@@ -123,4 +132,14 @@ const avatarFallback = computed(() => {
|
||||
const contact = convStore.current?.contact || {}
|
||||
return (contact.first_name || '').toUpperCase().substring(0, 2)
|
||||
})
|
||||
|
||||
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
|
||||
)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<div class="mb-1 space-y-0.5 text-xs text-muted-foreground">
|
||||
<p v-if="message.meta.from?.length">
|
||||
<span class="font-medium text-foreground">From:</span> {{ message.meta.from.join(', ') }}
|
||||
</p>
|
||||
<p v-if="message.meta.to?.length">
|
||||
<span class="font-medium text-foreground">To:</span> {{ message.meta.to.join(', ') }}
|
||||
</p>
|
||||
<p v-if="message.meta.cc?.length">
|
||||
<span class="font-medium text-foreground">CC:</span> {{ message.meta.cc.join(', ') }}
|
||||
</p>
|
||||
<p v-if="message.meta.bcc?.length">
|
||||
<span class="font-medium text-foreground">BCC:</span> {{ message.meta.bcc.join(', ') }}
|
||||
</p>
|
||||
<p v-if="message.meta.subject">
|
||||
<span class="font-medium text-foreground">Subject:</span> {{ message.meta.subject }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -1,10 +1,14 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="flex flex-col">
|
||||
<!-- Hide subject for email channel as each message shows the envelope -->
|
||||
<div
|
||||
class="flex flex-col"
|
||||
v-if="conversation.subject && conversation.inbox_channel !== 'email'"
|
||||
>
|
||||
<p class="font-medium">{{ $t('form.field.subject') }}</p>
|
||||
<Skeleton v-if="conversationStore.conversation.loading" class="w-32 h-4" />
|
||||
<p v-else>
|
||||
{{ conversation.subject || '-' }}
|
||||
{{ conversation.subject }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -180,7 +180,6 @@ const getValidationSchema = (attribute) => {
|
||||
// If regex is provided and valid, add it to the schema validation along with the hint
|
||||
if (attribute.regex) {
|
||||
try {
|
||||
console.log('Creating regex:', attribute.regex)
|
||||
const regex = new RegExp(attribute.regex)
|
||||
schema = schema.regex(regex, {
|
||||
message: attribute.regex_hint
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
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 { computeRecipientsFromMessage } from '@/utils/email-recipients'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents'
|
||||
import MessageCache from '@/utils/conversation-message-cache'
|
||||
@@ -12,6 +13,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(() => {
|
||||
@@ -105,6 +109,8 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
data: new MessageCache(),
|
||||
loading: false,
|
||||
page: 1,
|
||||
// To trigger reactivity on the messages cache, simpler than making MessageCache reactive.
|
||||
version: 0,
|
||||
})
|
||||
|
||||
let seenConversationUUIDs = new Map()
|
||||
@@ -113,6 +119,8 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
}, 120000)
|
||||
const emitter = useEmitter()
|
||||
|
||||
const incrementMessageVersion = () => setTimeout(() => messages.version++, 0)
|
||||
|
||||
function clearListReRenderInterval () {
|
||||
clearInterval(reRenderInterval)
|
||||
}
|
||||
@@ -245,12 +253,29 @@ 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
|
||||
watchEffect(async () => {
|
||||
const _ = messages.version // eslint-disable-line no-unused-vars
|
||||
const conv = conversation.data
|
||||
const msgData = messages.data
|
||||
const inboxEmail = conv?.inbox_mail
|
||||
if (!conv || !msgData || !inboxEmail) return
|
||||
|
||||
const currentCC = computed(() => {
|
||||
return conversation.data?.cc || []
|
||||
const latestMessage = msgData.getLatestMessage(conv.uuid, ['incoming', 'outgoing'], true)
|
||||
if (!latestMessage) return
|
||||
|
||||
if (!["received", "sent"].includes(latestMessage.status)) {
|
||||
return
|
||||
}
|
||||
|
||||
const { to, cc, bcc } = computeRecipientsFromMessage(
|
||||
latestMessage,
|
||||
conv.contact?.email || '',
|
||||
inboxEmail
|
||||
)
|
||||
currentTo.value = to
|
||||
currentCC.value = cc
|
||||
currentBCC.value = bcc
|
||||
})
|
||||
|
||||
async function fetchParticipants (uuid) {
|
||||
@@ -288,7 +313,7 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches messages for a conversation if not already cached.
|
||||
* Fetches messages for a conversation if not already present in the cache.
|
||||
*
|
||||
* @param {string} uuid
|
||||
* @returns
|
||||
@@ -312,6 +337,7 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
markConversationAsRead(uuid)
|
||||
// Cache messages
|
||||
messages.data.addMessages(uuid, newMessages, result.page, result.total_pages)
|
||||
incrementMessageVersion()
|
||||
} catch (error) {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Error',
|
||||
@@ -328,7 +354,7 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a single message from the server and adds it to the cache.
|
||||
* Fetches a single message from the server and adds it to the message cache.
|
||||
*
|
||||
* @param {string} conversationUUID
|
||||
* @param {string} messageUUID
|
||||
@@ -341,6 +367,7 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
const newMsg = response.data.data
|
||||
// Add message to cache.
|
||||
messages.data.addMessage(conversationUUID, newMsg)
|
||||
incrementMessageVersion()
|
||||
return newMsg
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -606,6 +633,7 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
const exists = messages.data.hasMessage(message.conversation_uuid, message.uuid)
|
||||
if (exists) {
|
||||
messages.data.updateMessageField(message.conversation_uuid, message.uuid, message.prop, message.value)
|
||||
incrementMessageVersion()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -647,6 +675,7 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
hasConversationOpen,
|
||||
current,
|
||||
currentContactName,
|
||||
currentTo,
|
||||
currentBCC,
|
||||
currentCC,
|
||||
clearListReRenderInterval,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export default class MessageCache {
|
||||
/**
|
||||
* Cache for conversation messages with LRU eviction
|
||||
* @param {number} maxConvs - Max conversations to store
|
||||
* Cache for conversation messages with eviction of old conversations
|
||||
* @param {number} maxConvs - Max conversations to store before eviction
|
||||
*/
|
||||
constructor(maxConvs = 100) {
|
||||
this.cache = new Map()
|
||||
@@ -69,6 +69,34 @@ 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
|
||||
|
||||
// Get all messages from all pages
|
||||
let allMessages = Array.from(conv.pages.values()).flat()
|
||||
|
||||
// Apply filters
|
||||
if (type.length > 0) {
|
||||
allMessages = allMessages.filter(msg => type.includes(msg.type))
|
||||
}
|
||||
if (excludePrivate) {
|
||||
allMessages = allMessages.filter(msg => !msg.private)
|
||||
}
|
||||
|
||||
// Sort messages by created_at in descending order (newest first)
|
||||
allMessages.sort((a, b) => new Date(b.created_at) - new Date(a.created_at))
|
||||
|
||||
return allMessages.length ? allMessages[0] : null
|
||||
}
|
||||
/**
|
||||
* Updates message fields by applying update object
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
export function computeRecipientsFromMessage (message, contactEmail, inboxEmail) {
|
||||
const meta = message?.meta || {}
|
||||
const isIncoming = message.type === 'incoming'
|
||||
|
||||
// Build TO field
|
||||
const toList = isIncoming
|
||||
? meta.from && meta.from.length
|
||||
? meta.from
|
||||
: contactEmail
|
||||
? [contactEmail]
|
||||
: []
|
||||
: meta.to && meta.to.length
|
||||
? meta.to
|
||||
: contactEmail
|
||||
? [contactEmail]
|
||||
: []
|
||||
|
||||
// Build CC field
|
||||
let ccList = meta.cc || []
|
||||
|
||||
if (isIncoming) {
|
||||
// Include original 'to' recipients in CC to preserve full thread context (e.g. other participants)
|
||||
if (Array.isArray(meta.to))
|
||||
ccList = ccList.concat(meta.to)
|
||||
|
||||
// If someone else replies (not the original contact), re-add original contact to CC to keep them in the loop.
|
||||
if (
|
||||
contactEmail &&
|
||||
!toList.includes(contactEmail) &&
|
||||
!ccList.includes(contactEmail)
|
||||
) {
|
||||
ccList.push(contactEmail)
|
||||
}
|
||||
}
|
||||
|
||||
// Dedup + remove inbox email
|
||||
const clean = list =>
|
||||
Array.from(new Set(list.filter(email => email && email !== inboxEmail)))
|
||||
|
||||
return {
|
||||
to: clean(toList),
|
||||
cc: clean(ccList),
|
||||
// BCC stays empty user is supposed to add it manually.
|
||||
bcc: [],
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import (
|
||||
mmodels "github.com/abhinavxd/libredesk/internal/media/models"
|
||||
notifier "github.com/abhinavxd/libredesk/internal/notification"
|
||||
slaModels "github.com/abhinavxd/libredesk/internal/sla/models"
|
||||
"github.com/abhinavxd/libredesk/internal/stringutil"
|
||||
tmodels "github.com/abhinavxd/libredesk/internal/team/models"
|
||||
"github.com/abhinavxd/libredesk/internal/template"
|
||||
umodels "github.com/abhinavxd/libredesk/internal/user/models"
|
||||
@@ -41,11 +42,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: <a href=\"%s\">Rate now</a>"
|
||||
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: <a href=\"%s\">Rate now</a>"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -184,7 +185,6 @@ func New(
|
||||
|
||||
type queries struct {
|
||||
// Conversation queries.
|
||||
GetToAddress *sqlx.Stmt `query:"get-to-address"`
|
||||
GetConversationUUID *sqlx.Stmt `query:"get-conversation-uuid"`
|
||||
GetConversation *sqlx.Stmt `query:"get-conversation"`
|
||||
GetConversationsCreatedAfter *sqlx.Stmt `query:"get-conversations-created-after"`
|
||||
@@ -213,6 +213,7 @@ 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"`
|
||||
|
||||
// Dashboard queries.
|
||||
GetDashboardCharts string `query:"get-dashboard-charts"`
|
||||
@@ -254,6 +255,14 @@ func (c *Manager) GetConversation(id int, uuid string) (models.Conversation, err
|
||||
c.lo.Error("error fetching conversation", "error", err)
|
||||
return conversation, envelope.NewError(envelope.GeneralError, c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.conversation}"), nil)
|
||||
}
|
||||
|
||||
// Strip name and extract plain email from "Name <email>"
|
||||
var err error
|
||||
conversation.InboxMail, err = stringutil.ExtractEmail(conversation.InboxMail)
|
||||
if err != nil {
|
||||
c.lo.Error("error extracting email from inbox mail", "inbox_mail", conversation.InboxMail, "error", err)
|
||||
}
|
||||
|
||||
return conversation, nil
|
||||
}
|
||||
|
||||
@@ -738,16 +747,6 @@ func (c *Manager) SetConversationTags(uuid string, action string, tagNames []str
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetToAddress retrieves the recipient addresses for a conversation and channel.
|
||||
func (m *Manager) GetToAddress(conversationID int) ([]string, error) {
|
||||
var addr []string
|
||||
if err := m.q.GetToAddress.Select(&addr, conversationID); err != nil {
|
||||
m.lo.Error("error fetching `to` address for message", "error", err, "conversation_id", conversationID)
|
||||
return addr, err
|
||||
}
|
||||
return addr, nil
|
||||
}
|
||||
|
||||
// GetMessageSourceIDs retrieves source IDs for messages in a conversation in descending order.
|
||||
// So the oldest message will be the last in the list.
|
||||
func (m *Manager) GetMessageSourceIDs(conversationID, limit int) ([]string, error) {
|
||||
@@ -872,23 +871,53 @@ func (m *Manager) ApplyAction(action amodels.RuleAction, conv models.Conversatio
|
||||
|
||||
switch action.Type {
|
||||
case amodels.ActionAssignTeam:
|
||||
teamID, _ := strconv.Atoi(action.Value[0])
|
||||
teamID, err := strconv.Atoi(action.Value[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid team ID %q: %w", action.Value[0], err)
|
||||
}
|
||||
return m.UpdateConversationTeamAssignee(conv.UUID, teamID, user)
|
||||
case amodels.ActionAssignUser:
|
||||
agentID, _ := strconv.Atoi(action.Value[0])
|
||||
agentID, err := strconv.Atoi(action.Value[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid agent ID %q: %w", action.Value[0], err)
|
||||
}
|
||||
return m.UpdateConversationUserAssignee(conv.UUID, agentID, user)
|
||||
case amodels.ActionSetPriority:
|
||||
priorityID, _ := strconv.Atoi(action.Value[0])
|
||||
priorityID, err := strconv.Atoi(action.Value[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid priority ID %q: %w", action.Value[0], err)
|
||||
}
|
||||
return m.UpdateConversationPriority(conv.UUID, priorityID, "", user)
|
||||
case amodels.ActionSetStatus:
|
||||
statusID, _ := strconv.Atoi(action.Value[0])
|
||||
statusID, err := strconv.Atoi(action.Value[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid status ID %q: %w", action.Value[0], err)
|
||||
}
|
||||
return m.UpdateConversationStatus(conv.UUID, statusID, "", "", user)
|
||||
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)
|
||||
// Make recipient list.
|
||||
to, cc, bcc, err := m.makeRecipients(conv.ID, conv.Contact.Email.String, conv.InboxMail)
|
||||
if err != nil {
|
||||
return fmt.Errorf("making recipients for reply action: %w", err)
|
||||
}
|
||||
return m.SendReply(
|
||||
[]mmodels.Media{},
|
||||
conv.InboxID,
|
||||
user.ID,
|
||||
conv.UUID,
|
||||
action.Value[0],
|
||||
to,
|
||||
cc,
|
||||
bcc,
|
||||
map[string]any{}, /**meta**/
|
||||
)
|
||||
case amodels.ActionSetSLA:
|
||||
slaID, _ := strconv.Atoi(action.Value[0])
|
||||
slaID, err := strconv.Atoi(action.Value[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid SLA ID %q: %w", action.Value[0], err)
|
||||
}
|
||||
return m.ApplySLA(conv, slaID, user)
|
||||
case amodels.ActionAddTags, amodels.ActionSetTags, amodels.ActionRemoveTags:
|
||||
return m.SetConversationTags(conv.UUID, action.Type, action.Value, user)
|
||||
@@ -924,7 +953,14 @@ 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)
|
||||
|
||||
// Make recipient list.
|
||||
to, cc, bcc, err := m.makeRecipients(conversation.ID, conversation.Contact.Email.String, conversation.InboxMail)
|
||||
if err != nil {
|
||||
return fmt.Errorf("making recipients for CSAT reply: %w", err)
|
||||
}
|
||||
|
||||
return m.SendReply(nil /**media**/, conversation.InboxID, actorUserID, conversation.UUID, message, to, cc, bcc, meta)
|
||||
}
|
||||
|
||||
// DeleteConversation deletes a conversation.
|
||||
|
||||
@@ -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.
|
||||
@@ -839,3 +841,16 @@ 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
|
||||
}
|
||||
|
||||
@@ -74,6 +74,7 @@ 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"`
|
||||
InboxMail string `db:"inbox_mail" json:"inbox_mail"`
|
||||
InboxName string `db:"inbox_name" json:"inbox_name"`
|
||||
InboxChannel string `db:"inbox_channel" json:"inbox_channel"`
|
||||
Tags null.JSON `db:"tags" json:"tags"`
|
||||
@@ -89,6 +90,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 +133,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:"-"`
|
||||
|
||||
@@ -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,
|
||||
@@ -104,6 +89,8 @@ SELECT
|
||||
c.closed_at,
|
||||
c.resolved_at,
|
||||
c.inbox_id,
|
||||
COALESCE(inb.from, '') as inbox_mail,
|
||||
COALESCE(inb.channel::TEXT, '') as inbox_channel,
|
||||
c.status_id,
|
||||
c.priority_id,
|
||||
p.name as priority,
|
||||
@@ -118,6 +105,7 @@ SELECT
|
||||
c.subject,
|
||||
c.contact_id,
|
||||
c.sla_policy_id,
|
||||
c.meta,
|
||||
sla.name as sla_policy_name,
|
||||
c.last_message,
|
||||
c.custom_attributes,
|
||||
@@ -138,18 +126,16 @@ 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
|
||||
FROM conversations c
|
||||
JOIN users ct ON c.contact_id = ct.id
|
||||
JOIN inboxes inb ON c.inbox_id = inb.id
|
||||
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
|
||||
@@ -375,12 +361,6 @@ FROM conversation_tags ct
|
||||
JOIN tags t ON ct.tag_id = t.id
|
||||
WHERE ct.conversation_id = (SELECT id FROM conversations WHERE uuid = $1);
|
||||
|
||||
-- name: get-to-address
|
||||
SELECT cc.identifier
|
||||
FROM conversations c
|
||||
INNER JOIN contact_channels cc ON cc.id = c.contact_channel_id
|
||||
WHERE c.id = $1;
|
||||
|
||||
-- name: get-conversation-uuid-from-message-uuid
|
||||
SELECT c.uuid AS conversation_uuid
|
||||
FROM conversation_messages m
|
||||
@@ -424,6 +404,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
|
||||
@@ -576,4 +557,24 @@ WHERE
|
||||
)
|
||||
|
||||
-- name: delete-conversation
|
||||
DELETE FROM conversations WHERE uuid = $1;
|
||||
DELETE FROM conversations WHERE uuid = $1;
|
||||
|
||||
-- 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;
|
||||
@@ -0,0 +1,33 @@
|
||||
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 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, isIncoming,
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -191,3 +192,67 @@ func ValidEmail(email string) bool {
|
||||
}
|
||||
return addr.Name == "" && addr.Address == email
|
||||
}
|
||||
|
||||
// ExtractEmail extracts the email address from a string.
|
||||
func ExtractEmail(s string) (string, error) {
|
||||
addr, err := mail.ParseAddress(s)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return addr.Address, nil
|
||||
}
|
||||
|
||||
// DedupAndExcludeString returns a deduplicated []string excluding empty and a specific value.
|
||||
func DedupAndExcludeString(list []string, exclude string) []string {
|
||||
seen := make(map[string]struct{}, len(list))
|
||||
cleaned := make([]string, 0, len(list))
|
||||
for _, s := range list {
|
||||
if s == "" || s == exclude {
|
||||
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 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 = DedupAndExcludeString(finalTo, inboxEmail)
|
||||
finalCC = DedupAndExcludeString(finalCC, inboxEmail)
|
||||
// BCC is one-time only, user is supposed to add it manually.
|
||||
finalBCC = []string{}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user