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:
Abhinav Raut
2025-05-15 11:08:55 +05:30
committed by GitHub
21 changed files with 590 additions and 236 deletions
+14 -8
View File
@@ -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
View File
@@ -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.
+1 -9
View File
@@ -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>
+43 -48
View File
@@ -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
+37 -8
View File
@@ -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
*/
+46
View File
@@ -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: [],
}
}
+59 -23
View File
@@ -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.
+32 -17
View File
@@ -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
}
+5 -3
View File
@@ -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:"-"`
+26 -25
View File
@@ -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;
+33
View File
@@ -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
}
+32 -9
View File
@@ -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,
+65
View File
@@ -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
}