feat: Enable agents to create conversations from the UI

Before this feature the only way to create a conversation was by adding inbox and sending an email.

Agents first search contacts by email, see a dropdown select an existing contact or fill a new email for new contact.

The backend creates contact if it does not exist, creates a conversation, sends a reply to the conversation.
Optinally assigns conversation to a user / team.

fix: Replies to emails create a new conversation instead of attaching to the previous one.

Was not happening in gmail, as gmail was sending the references headers in all replies and I missed this completely. So when libredesk searches a conversation by references headers it worked!

Instead the right way is to generate the outgoing email message id and saving it in DB. This commit fixes that.

There could be more backup strategies like putting reference number in the subject but that can be explored later.

chore: new role `conversatons:write` that enables the create conversations feature for an agent.

chore: migrations for v0.4.0.
This commit is contained in:
Abhinav Raut
2025-03-05 01:17:42 +05:30
parent 360557c58f
commit 494bc15b0a
31 changed files with 711 additions and 81 deletions

View File

@@ -75,7 +75,7 @@ func handleOIDCCallback(r *fastglue.Request) error {
}
// Lookup the user by email and set the session.
user, err := app.user.GetByEmail(claims.Email)
user, err := app.user.GetAgentByEmail(claims.Email)
if err != nil {
return sendErrorEnvelope(r, err)
}

View File

@@ -3,6 +3,7 @@ package main
import (
"encoding/json"
"strconv"
"strings"
"time"
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
@@ -42,7 +43,6 @@ func handleGetAllConversations(r *fastglue.Request) error {
if conversations[i].SLAPolicyID.Int != 0 {
setSLADeadlines(app, &conversations[i])
}
conversations[i].ID = 0
}
return r.SendEnvelope(envelope.PageResults{
@@ -79,7 +79,6 @@ func handleGetAssignedConversations(r *fastglue.Request) error {
if conversations[i].SLAPolicyID.Int != 0 {
setSLADeadlines(app, &conversations[i])
}
conversations[i].ID = 0
}
return r.SendEnvelope(envelope.PageResults{
@@ -116,7 +115,6 @@ func handleGetUnassignedConversations(r *fastglue.Request) error {
if conversations[i].SLAPolicyID.Int != 0 {
setSLADeadlines(app, &conversations[i])
}
conversations[i].ID = 0
}
return r.SendEnvelope(envelope.PageResults{
@@ -153,7 +151,7 @@ func handleGetViewConversations(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusForbidden, "You don't have access to this view.", nil, envelope.PermissionError)
}
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -195,7 +193,6 @@ func handleGetViewConversations(r *fastglue.Request) error {
if conversations[i].SLAPolicyID.Int != 0 {
setSLADeadlines(app, &conversations[i])
}
conversations[i].ID = 0
}
return r.SendEnvelope(envelope.PageResults{
@@ -248,7 +245,6 @@ func handleGetTeamUnassignedConversations(r *fastglue.Request) error {
if conversations[i].SLAPolicyID.Int != 0 {
setSLADeadlines(app, &conversations[i])
}
conversations[i].ID = 0
}
return r.SendEnvelope(envelope.PageResults{
@@ -268,7 +264,7 @@ func handleGetConversation(r *fastglue.Request) error {
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -284,7 +280,6 @@ func handleGetConversation(r *fastglue.Request) error {
prev, _ := app.conversation.GetContactConversations(conv.ContactID)
conv.PreviousConversations = filterCurrentConv(prev, conv.UUID)
conv.ID = 0
return r.SendEnvelope(conv)
}
@@ -295,7 +290,7 @@ func handleUpdateConversationAssigneeLastSeen(r *fastglue.Request) error {
uuid = r.RequestCtx.UserValue("uuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -316,7 +311,7 @@ func handleGetConversationParticipants(r *fastglue.Request) error {
uuid = r.RequestCtx.UserValue("uuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -343,7 +338,7 @@ func handleUpdateUserAssignee(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid `assignee_id`", nil, envelope.InputError)
}
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -375,7 +370,7 @@ func handleUpdateTeamAssignee(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid assignee `id`.", nil, envelope.InputError)
}
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -426,7 +421,7 @@ func handleUpdateConversationPriority(r *fastglue.Request) error {
if err != nil {
return sendErrorEnvelope(r, err)
}
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -471,7 +466,7 @@ func handleUpdateConversationStatus(r *fastglue.Request) error {
}
// Enforce conversation access.
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -528,7 +523,7 @@ func handleUpdateConversationtags(r *fastglue.Request) error {
return sendErrorEnvelope(r, err)
}
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -607,7 +602,7 @@ func handleRemoveUserAssignee(r *fastglue.Request) error {
uuid = r.RequestCtx.UserValue("uuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -628,7 +623,7 @@ func handleRemoveTeamAssignee(r *fastglue.Request) error {
uuid = r.RequestCtx.UserValue("uuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -651,3 +646,101 @@ func filterCurrentConv(convs []cmodels.Conversation, uuid string) []cmodels.Conv
}
return []cmodels.Conversation{}
}
// handleCreateConversation creates a new conversation and sends a message to it.
func handleCreateConversation(r *fastglue.Request) error {
var (
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
inboxID = r.RequestCtx.PostArgs().GetUintOrZero("inbox_id")
assignedAgentID = r.RequestCtx.PostArgs().GetUintOrZero("agent_id")
assignedTeamID = r.RequestCtx.PostArgs().GetUintOrZero("team_id")
email = strings.TrimSpace(string(r.RequestCtx.PostArgs().Peek("contact_email")))
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"))
)
// Validate required fields
if inboxID <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "inbox_id is required", nil, envelope.InputError)
}
if subject == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "subject is required", nil, envelope.InputError)
}
if content == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "content is required", nil, envelope.InputError)
}
if email == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Contact email is required", nil, envelope.InputError)
}
if firstName == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "First name is required when creating a new contact", nil, envelope.InputError)
}
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
// Check if inbox exists and is enabled.
inbox, err := app.inbox.GetDBRecord(inboxID)
if err != nil {
return sendErrorEnvelope(r, err)
}
if !inbox.Enabled {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "The chosen inbox is disabled", nil, envelope.InputError)
}
// Find or create contact.
contact := umodels.User{
Email: null.StringFrom(email),
SourceChannelID: null.StringFrom(email),
FirstName: firstName,
LastName: lastName,
InboxID: inboxID,
}
if err := app.user.CreateContact(&contact); err != nil {
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, "Error creating contact", nil))
}
// Create conversation
conversationID, conversationUUID, err := app.conversation.CreateConversation(
contact.ID,
contact.ContactChannelID,
inboxID,
"", /** last_message **/
time.Now(),
subject,
)
if err != nil {
app.lo.Error("error creating conversation", "error", err)
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, "Error creating conversation", nil))
}
// Send reply to the created conversation.
if err := app.conversation.SendReply(nil /**media**/, inboxID, auser.ID, conversationUUID, content, nil /**cc**/, nil /**bcc**/, map[string]any{} /**meta**/); err != nil {
if err := app.conversation.DeleteConversation(conversationUUID); err != nil {
app.lo.Error("error deleting conversation", "error", err)
}
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, "Error sending message", nil))
}
// Assign the conversation to the agent or team.
if assignedAgentID > 0 {
app.conversation.UpdateConversationUserAssignee(conversationUUID, assignedAgentID, user)
}
if assignedTeamID > 0 {
app.conversation.UpdateConversationTeamAssignee(conversationUUID, assignedTeamID, user)
}
// Evaluate automation rules for the new conversation
app.automation.EvaluateNewConversationRules(conversationUUID)
// Send the created conversation back to the client.
conversation, err := app.conversation.GetConversation(conversationID, "")
if err != nil {
app.lo.Error("error fetching created conversation", "error", err)
}
return r.SendEnvelope(conversation)
}

View File

@@ -63,10 +63,12 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.GET("/api/v1/conversations/{uuid}/messages", perm(handleGetMessages, "messages:read"))
g.POST("/api/v1/conversations/{cuuid}/messages", perm(handleSendMessage, "messages:write"))
g.PUT("/api/v1/conversations/{cuuid}/messages/{uuid}/retry", perm(handleRetryMessage, "messages:write"))
g.POST("/api/v1/conversations", perm(handleCreateConversation, "conversations:write"))
// Search.
g.GET("/api/v1/conversations/search", perm(handleSearchConversations, "conversations:read"))
g.GET("/api/v1/messages/search", perm(handleSearchMessages, "messages:read"))
g.GET("/api/v1/contacts/search", perm(handleSearchContacts, "conversations:write"))
// Views.
g.GET("/api/v1/views/me", perm(handleGetUserViews, "view:manage"))

View File

@@ -145,7 +145,7 @@ func handleApplyMacro(r *fastglue.Request) error {
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
incomingActions = []autoModels.RuleAction{}
)
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -239,7 +239,7 @@ func setDisplayValues(app *App, actions []autoModels.RuleAction) error {
return t.Name, nil
},
autoModels.ActionAssignUser: func(id int) (string, error) {
u, err := app.user.Get(id)
u, err := app.user.GetAgent(id)
if err != nil {
app.lo.Warn("user not found for macro action", "user_id", id)
return "", err

View File

@@ -150,7 +150,7 @@ func handleServeMedia(r *fastglue.Request) error {
uuid = r.RequestCtx.UserValue("uuid").(string)
)
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}

View File

@@ -30,7 +30,7 @@ func handleGetMessages(r *fastglue.Request) error {
total = 0
)
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -70,7 +70,7 @@ func handleGetMessage(r *fastglue.Request) error {
cuuid = r.RequestCtx.UserValue("cuuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -105,7 +105,7 @@ func handleRetryMessage(r *fastglue.Request) error {
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -133,13 +133,13 @@ func handleSendMessage(r *fastglue.Request) error {
req = messageReq{}
)
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
// Check permission
_, err = enforceConversationAccess(app, cuuid, user)
conv, err := enforceConversationAccess(app, cuuid, user)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -163,7 +163,7 @@ func handleSendMessage(r *fastglue.Request) error {
return sendErrorEnvelope(r, err)
}
} else {
if err := app.conversation.SendReply(media, user.ID, cuuid, req.Message, req.CC, req.BCC, map[string]interface{}{}); err != nil {
if err := app.conversation.SendReply(media, conv.InboxID, user.ID, cuuid, req.Message, req.CC, req.BCC, map[string]any{} /**meta**/); err != nil {
return sendErrorEnvelope(r, err)
}
// Evaluate automation rules.

View File

@@ -24,7 +24,7 @@ func tryAuth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
}
// Try to get user.
user, err := app.user.Get(userSession.ID)
user, err := app.user.GetAgent(userSession.ID)
if err != nil {
return handler(r)
}
@@ -54,7 +54,7 @@ func auth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
}
// Set user in the request context.
user, err := app.user.Get(userSession.ID)
user, err := app.user.GetAgent(userSession.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -91,7 +91,7 @@ func perm(handler fastglue.FastRequestHandler, perm string) fastglue.FastRequest
}
// Get user from DB.
user, err := app.user.Get(sessUser.ID)
user, err := app.user.GetAgent(sessUser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}

View File

@@ -44,3 +44,19 @@ func handleSearchMessages(r *fastglue.Request) error {
}
return r.SendEnvelope(messages)
}
// handleSearchContacts searches contacts based on the query.
func handleSearchContacts(r *fastglue.Request) error {
var (
app = r.Context.(*App)
q = string(r.RequestCtx.QueryArgs().Peek("query"))
)
if len(q) < minSearchQueryLength {
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, "Query length should be at least 3 characters", nil))
}
contacts, err := app.search.Contacts(q)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(contacts)
}

View File

@@ -2,6 +2,7 @@ package main
import (
"encoding/json"
"net/mail"
"strings"
"github.com/abhinavxd/libredesk/internal/envelope"
@@ -100,6 +101,11 @@ func handleUpdateEmailNotificationSettings(r *fastglue.Request) error {
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, "Error updating settings", nil))
}
// Make sure it's a valid from email address.
if _, err := mail.ParseAddress(req.EmailAddress); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid from email address format", nil, envelope.InputError)
}
if req.Password == "" {
req.Password = cur.Password
}
@@ -107,5 +113,7 @@ func handleUpdateEmailNotificationSettings(r *fastglue.Request) error {
if err := app.setting.Update(req); err != nil {
return sendErrorEnvelope(r, err)
}
// No reload implemented, so user has to restart the app.
return r.SendEnvelope("Settings updated successfully, Please restart the app for changes to take effect.")
}

View File

@@ -57,7 +57,7 @@ func handleGetUser(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
"Invalid user `id`.", nil, envelope.InputError)
}
user, err := app.user.Get(id)
user, err := app.user.GetAgent(id)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -83,7 +83,7 @@ func handleGetCurrentUserTeams(r *fastglue.Request) error {
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -101,13 +101,7 @@ func handleUpdateCurrentUser(r *fastglue.Request) error {
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
user, err := app.user.Get(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
// Get current user.
currentUser, err := app.user.Get(user.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -165,8 +159,8 @@ func handleUpdateCurrentUser(r *fastglue.Request) error {
}
// Delete current avatar.
if currentUser.AvatarURL.Valid {
fileName := filepath.Base(currentUser.AvatarURL.String)
if user.AvatarURL.Valid {
fileName := filepath.Base(user.AvatarURL.String)
app.media.Delete(fileName)
}
@@ -316,7 +310,7 @@ func handleGetCurrentUser(r *fastglue.Request) error {
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
u, err := app.user.Get(auser.ID)
u, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -331,7 +325,7 @@ func handleDeleteAvatar(r *fastglue.Request) error {
)
// Get user
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -370,7 +364,7 @@ func handleResetPassword(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty `email`", nil, envelope.InputError)
}
user, err := app.user.GetByEmail(email)
user, err := app.user.GetAgentByEmail(email)
if err != nil {
// Send 200 even if user not found, to prevent email enumeration.
return r.SendEnvelope("Reset password email sent successfully.")

View File

@@ -16,7 +16,7 @@ func handleGetUserViews(r *fastglue.Request) error {
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -37,7 +37,7 @@ func handleCreateUserView(r *fastglue.Request) error {
if err := r.Decode(&view, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
}
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -71,7 +71,7 @@ func handleDeleteUserView(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty view `ID`", nil, envelope.InputError)
}
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -109,7 +109,7 @@ func handleUpdateUserView(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
}
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}

View File

@@ -46,6 +46,7 @@
@create-view="openCreateViewForm = true"
@edit-view="editView"
@delete-view="deleteView"
@create-conversation="() => openCreateConversationDialog = true"
>
<div class="flex flex-col h-screen">
<!-- Show app update only in admin routes -->
@@ -64,6 +65,9 @@
<!-- Command box -->
<Command />
<!-- Create conversation dialog -->
<CreateConversation v-model="openCreateConversationDialog" />
</template>
<script setup>
@@ -89,6 +93,7 @@ import api from '@/api'
import { toast as sooner } from 'vue-sonner'
import Sidebar from '@/components/sidebar/Sidebar.vue'
import Command from '@/features/command/CommandBox.vue'
import CreateConversation from '@/features/conversation/CreateConversation.vue'
import { Inbox, Shield, FileLineChart } from 'lucide-vue-next'
import { useRoute } from 'vue-router'
import {
@@ -117,6 +122,7 @@ const tagStore = useTagStore()
const userViews = ref([])
const view = ref({})
const openCreateViewForm = ref(false)
const openCreateConversationDialog = ref(false)
initWS()
useIdleDetection()

View File

@@ -35,6 +35,7 @@ http.interceptors.request.use((request) => {
const searchConversations = (params) => http.get('/api/v1/conversations/search', { params })
const searchMessages = (params) => http.get('/api/v1/messages/search', { params })
const searchContacts = (params) => http.get('/api/v1/contacts/search', { params })
const resetPassword = (data) => http.post('/api/v1/users/reset-password', data)
const setPassword = (data) => http.post('/api/v1/users/set-password', data)
const deleteUser = (id) => http.delete(`/api/v1/users/${id}`)
@@ -174,6 +175,7 @@ const getTags = () => http.get('/api/v1/tags')
const upsertTags = (uuid, data) => http.post(`/api/v1/conversations/${uuid}/tags`, data)
const updateAssignee = (uuid, assignee_type, data) => http.put(`/api/v1/conversations/${uuid}/assignee/${assignee_type}`, data)
const removeAssignee = (uuid, assignee_type) => http.put(`/api/v1/conversations/${uuid}/assignee/${assignee_type}/remove`)
const createConversation = (data) => http.post('/api/v1/conversations', data)
const updateConversationStatus = (uuid, data) => http.put(`/api/v1/conversations/${uuid}/status`, data)
const updateConversationPriority = (uuid, data) => http.put(`/api/v1/conversations/${uuid}/priority`, data)
const updateAssigneeLastSeen = (uuid) => http.put(`/api/v1/conversations/${uuid}/last-seen`)
@@ -333,6 +335,7 @@ export default {
createAutomationRule,
toggleAutomationRule,
deleteAutomationRule,
createConversation,
sendMessage,
retryMessage,
createUser,
@@ -377,5 +380,6 @@ export default {
aiCompletion,
searchConversations,
searchMessages,
searchContacts,
removeAssignee,
}

View File

@@ -46,7 +46,7 @@ defineProps({
const userStore = useUserStore()
const settingsStore = useAppSettingsStore()
const route = useRoute()
const emit = defineEmits(['createView', 'editView', 'deleteView'])
const emit = defineEmits(['createView', 'editView', 'deleteView', 'createConversation'])
const openCreateViewDialog = () => {
emit('createView')
@@ -230,15 +230,27 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
<div class="flex items-center justify-between w-full">
<div class="font-semibold text-xl">Inbox</div>
<div class="ml-auto">
<router-link :to="{ name: 'search' }">
<div class="flex items-center bg-accent p-2 rounded-full">
<Search
class="transition-transform duration-200 hover:scale-110 cursor-pointer"
<div class="flex items-center space-x-2">
<div
class="flex items-center bg-accent p-2 rounded-full cursor-pointer"
@click="emit('createConversation')"
>
<Plus
class="transition-transform duration-200 hover:scale-110"
size="15"
stroke-width="2.5"
/>
</div>
</router-link>
<router-link :to="{ name: 'search' }">
<div class="flex items-center bg-accent p-2 rounded-full">
<Search
class="transition-transform duration-200 hover:scale-110 cursor-pointer"
size="15"
stroke-width="2.5"
/>
</div>
</router-link>
</div>
</div>
</div>
</SidebarMenuButton>

View File

@@ -88,6 +88,7 @@ const permissions = ref([
name: 'Conversation',
permissions: [
{ name: 'conversations:read', label: 'View conversation' },
{ name: 'conversations:write', label: 'Create conversation' },
{ name: 'conversations:read_assigned', label: 'View conversations assigned to me' },
{ name: 'conversations:read_all', label: 'View all conversations' },
{ name: 'conversations:read_unassigned', label: 'View all unassigned conversations' },

View File

@@ -0,0 +1,345 @@
<template>
<Dialog :open="dialogOpen" @update:open="dialogOpen = false">
<DialogContent class="max-w-5xl w-full h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle>New Conversation</DialogTitle>
</DialogHeader>
<form @submit="createConversation" class="flex flex-col flex-1 overflow-hidden">
<div class="flex-1 space-y-4 pr-1 overflow-y-auto pb-2">
<FormField name="contact_email">
<FormItem class="relative">
<FormLabel>Email</FormLabel>
<FormControl>
<Input
type="email"
placeholder="Search contact by email or type new email"
v-model="emailQuery"
@input="handleSearchContacts"
autocomplete="off"
/>
</FormControl>
<FormMessage />
<ul
v-if="searchResults.length"
class="border rounded p-2 max-h-60 overflow-y-auto absolute bg-white w-full z-50 shadow-lg"
>
<li
v-for="contact in searchResults"
:key="contact.email"
@click="selectContact(contact)"
class="cursor-pointer p-2 hover:bg-gray-100 rounded"
>
{{ contact.first_name }} {{ contact.last_name }} ({{ contact.email }})
</li>
</ul>
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="first_name">
<FormItem>
<FormLabel>First Name</FormLabel>
<FormControl>
<Input type="text" placeholder="First Name" v-bind="componentField" required />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="last_name">
<FormItem>
<FormLabel>Last Name</FormLabel>
<FormControl>
<Input type="text" placeholder="Last Name" v-bind="componentField" required />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="subject">
<FormItem>
<FormLabel>Subject</FormLabel>
<FormControl>
<Input type="text" placeholder="Subject" v-bind="componentField" required />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="inbox_id">
<FormItem>
<FormLabel>Inbox</FormLabel>
<FormControl>
<Select v-bind="componentField">
<SelectTrigger>
<SelectValue placeholder="Select an inbox" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem
v-for="option in inboxStore.options"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Set assigned team -->
<FormField v-slot="{ componentField }" name="team_id">
<FormItem>
<FormLabel>Assign team (optional)</FormLabel>
<FormControl>
<ComboBox
v-bind="componentField"
:items="[{ value: 'none', label: 'None' }, ...teamStore.options]"
placeholder="Search team"
defaultLabel="Assign team"
>
<template #item="{ item }">
<div class="flex items-center gap-3 py-2">
<div class="w-7 h-7 flex items-center justify-center">
<span v-if="item.emoji">{{ item.emoji }}</span>
<div
v-else
class="text-primary bg-muted rounded-full w-7 h-7 flex items-center justify-center"
>
<Users size="14" />
</div>
</div>
<span class="text-sm">{{ item.label }}</span>
</div>
</template>
<template #selected="{ selected }">
<div class="flex items-center gap-3" v-if="selected">
<div class="w-7 h-7 flex items-center justify-center">
{{ selected?.emoji }}
</div>
<span class="text-sm">{{ selected?.label || 'Select team' }}</span>
</div>
</template>
</ComboBox>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Set assigned agent -->
<FormField v-slot="{ componentField }" name="agent_id">
<FormItem>
<FormLabel>Assign agent (optional)</FormLabel>
<FormControl>
<ComboBox
v-bind="componentField"
:items="[{ value: 'none', label: 'None' }, ...uStore.options]"
placeholder="Search agent"
defaultLabel="Assign agent"
>
<template #item="{ item }">
<div class="flex items-center gap-3 py-2">
<Avatar class="w-8 h-8">
<AvatarImage
:src="item.value === 'none' ? '/default-avatar.png' : item.avatar_url"
:alt="item.value === 'none' ? 'N' : item.label.slice(0, 2)"
/>
<AvatarFallback>
{{ item.value === 'none' ? 'N' : item.label.slice(0, 2).toUpperCase() }}
</AvatarFallback>
</Avatar>
<span class="text-sm">{{ item.label }}</span>
</div>
</template>
<template #selected="{ selected }">
<div class="flex items-center gap-3">
<Avatar class="w-7 h-7" v-if="selected">
<AvatarImage
:src="
selected?.value === 'none'
? '/default-avatar.png'
: selected?.avatar_url
"
:alt="selected?.value === 'none' ? 'N' : selected?.label?.slice(0, 2)"
/>
<AvatarFallback>
{{
selected?.value === 'none'
? 'N'
: selected?.label?.slice(0, 2)?.toUpperCase()
}}
</AvatarFallback>
</Avatar>
<span class="text-sm">{{ selected?.label || 'Assign agent' }}</span>
</div>
</template>
</ComboBox>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField
v-slot="{ componentField }"
name="content"
class="flex-1 min-h-0 flex flex-col"
>
<FormItem class="flex flex-col flex-1">
<FormLabel>Message</FormLabel>
<FormControl class="flex-1 min-h-0 flex flex-col">
<div class="flex-1 min-h-0 flex flex-col">
<Editor
v-model:htmlContent="componentField.modelValue"
@update:htmlContent="(value) => componentField.onChange(value)"
:placeholder="'Shift + Enter to add new line'"
class="w-full flex-1 overflow-y-auto p-2 min-h-[200px] box"
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</div>
<DialogFooter class="mt-4 pt-2 border-t shrink-0">
<Button type="submit" :disabled="loading" :isLoading="loading"> Submit </Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</template>
<script setup>
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
import { z } from 'zod'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { ref, defineModel, watch } from 'vue'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { useEmitter } from '@/composables/useEmitter'
import ComboBox from '@/components/ui/combobox/ComboBox.vue'
import { handleHTTPError } from '@/utils/http'
import { useInboxStore } from '@/stores/inbox'
import { useUsersStore } from '@/stores/users'
import { useTeamStore } from '@/stores/team'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import Editor from '@/features/conversation/ConversationTextEditor.vue'
import api from '@/api'
const dialogOpen = defineModel({
required: false,
default: () => false
})
const inboxStore = useInboxStore()
const uStore = useUsersStore()
const teamStore = useTeamStore()
const emitter = useEmitter()
const loading = ref(false)
const searchResults = ref([])
const emailQuery = ref('')
let timeoutId = null
const formSchema = z.object({
subject: z.string().min(3, 'Subject must be at least 3 characters'),
content: z.string().min(1, 'Message cannot be empty'),
inbox_id: z.any().refine((val) => inboxStore.options.some((option) => option.value === val), {
message: 'Inbox is required'
}),
team_id: z.any().optional(),
agent_id: z.any().optional(),
contact_email: z.string().email('Invalid email address'),
first_name: z.string().min(1, 'First name is required'),
last_name: z.string().min(1, 'Last name is required')
})
const form = useForm({
validationSchema: toTypedSchema(formSchema),
initialValues: {
inbox_id: null,
team_id: null,
agent_id: null,
subject: '',
content: '',
contact_email: '',
first_name: '',
last_name: ''
}
})
watch(emailQuery, (newVal) => {
form.setFieldValue('contact_email', newVal)
})
const handleSearchContacts = async () => {
clearTimeout(timeoutId)
timeoutId = setTimeout(async () => {
const query = emailQuery.value.trim()
if (query.length < 3) {
searchResults.value.splice(0)
return
}
try {
const resp = await api.searchContacts({ query })
searchResults.value = [...resp.data.data]
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',
variant: 'destructive',
description: handleHTTPError(error).message
})
searchResults.value.splice(0)
}
}, 300)
}
const selectContact = (contact) => {
emailQuery.value = contact.email
form.setFieldValue('first_name', contact.first_name)
form.setFieldValue('last_name', contact.last_name || '')
searchResults.value.splice(0)
}
const createConversation = form.handleSubmit(async (values) => {
loading.value = true
try {
await api.createConversation(values)
dialogOpen.value = false
form.resetForm()
emailQuery.value = ''
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',
variant: 'destructive',
description: handleHTTPError(error).message
})
} finally {
loading.value = false
}
})
</script>

View File

@@ -12,6 +12,7 @@ const (
PermConversationsUpdatePriority = "conversations:update_priority"
PermConversationsUpdateStatus = "conversations:update_status"
PermConversationsUpdateTags = "conversations:update_tags"
PermConversationWrite = "conversations:write"
PermMessagesRead = "messages:read"
PermMessagesWrite = "messages:write"
@@ -78,6 +79,7 @@ var validPermissions = map[string]struct{}{
PermConversationsUpdatePriority: {},
PermConversationsUpdateStatus: {},
PermConversationsUpdateTags: {},
PermConversationWrite: {},
PermMessagesRead: {},
PermMessagesWrite: {},
PermViewManage: {},

View File

@@ -24,6 +24,7 @@ import (
"github.com/abhinavxd/libredesk/internal/dbutil"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/abhinavxd/libredesk/internal/inbox"
imodels "github.com/abhinavxd/libredesk/internal/inbox/models"
mmodels "github.com/abhinavxd/libredesk/internal/media/models"
notifier "github.com/abhinavxd/libredesk/internal/notification"
slaModels "github.com/abhinavxd/libredesk/internal/sla/models"
@@ -96,7 +97,7 @@ type teamStore interface {
}
type userStore interface {
Get(int) (umodels.User, error)
GetAgent(int) (umodels.User, error)
GetSystemUser() (umodels.User, error)
CreateContact(user *umodels.User) error
}
@@ -112,6 +113,7 @@ type mediaStore interface {
type inboxStore interface {
Get(int) (inbox.Inbox, error)
GetDBRecord(int) (imodels.Inbox, error)
}
type settingsStore interface {
@@ -207,6 +209,7 @@ type queries struct {
UnassignOpenConversations *sqlx.Stmt `query:"unassign-open-conversations"`
ReOpenConversation *sqlx.Stmt `query:"re-open-conversation"`
UnsnoozeAll *sqlx.Stmt `query:"unsnooze-all"`
DeleteConversation *sqlx.Stmt `query:"delete-conversation"`
// Dashboard queries.
GetDashboardCharts string `query:"get-dashboard-charts"`
@@ -742,6 +745,9 @@ func (m *Manager) GetToAddress(conversationID int) ([]string, error) {
func (m *Manager) GetLatestReceivedMessageSourceID(conversationID int) (string, error) {
var out string
if err := m.q.GetLatestReceivedMessageSourceID.Get(&out, conversationID); err != nil {
if err == sql.ErrNoRows {
return out, nil
}
m.lo.Error("error fetching message source id", "error", err, "conversation_id", conversationID)
return out, err
}
@@ -750,7 +756,7 @@ func (m *Manager) GetLatestReceivedMessageSourceID(conversationID int) (string,
// SendAssignedConversationEmail sends a email for an assigned conversation to the passed user ids.
func (m *Manager) SendAssignedConversationEmail(userIDs []int, conversation models.Conversation) error {
agent, err := m.userStore.Get(userIDs[0])
agent, err := m.userStore.GetAgent(userIDs[0])
if err != nil {
m.lo.Error("error fetching agent", "user_id", userIDs[0], "error", err)
return fmt.Errorf("fetching agent: %w", err)
@@ -875,7 +881,7 @@ func (m *Manager) ApplyAction(action amodels.RuleAction, conv models.Conversatio
case amodels.ActionSendPrivateNote:
return m.SendPrivateNote([]mmodels.Media{}, user.ID, conv.UUID, action.Value[0])
case amodels.ActionReply:
return m.SendReply([]mmodels.Media{}, user.ID, conv.UUID, action.Value[0], nil, nil, nil)
return m.SendReply([]mmodels.Media{}, conv.InboxID, user.ID, conv.UUID, action.Value[0], nil, nil, nil)
case amodels.ActionSetSLA:
slaID, _ := strconv.Atoi(action.Value[0])
return m.ApplySLA(conv, slaID, user)
@@ -913,7 +919,16 @@ func (m *Manager) SendCSATReply(actorUserID int, conversation models.Conversatio
meta := map[string]interface{}{
"is_csat": true,
}
return m.SendReply([]mmodels.Media{}, actorUserID, conversation.UUID, message, nil, nil, meta)
return m.SendReply([]mmodels.Media{}, conversation.InboxID, actorUserID, conversation.UUID, message, nil, nil, meta)
}
// DeleteConversation deletes a conversation.
func (m *Manager) DeleteConversation(uuid string) error {
if _, err := m.q.DeleteConversation.Exec(uuid); err != nil {
m.lo.Error("error deleting conversation", "error", err)
return envelope.NewError(envelope.GeneralError, "Error deleting conversation", nil)
}
return nil
}
// addConversationParticipant adds a user as participant to a conversation.

View File

@@ -313,11 +313,10 @@ 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, senderID int, conversationUUID, content string, cc, bcc []string, meta map[string]interface{}) error {
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.
cc = stringutil.RemoveEmpty(cc)
bcc = stringutil.RemoveEmpty(bcc)
// Save cc and bcc as JSON in meta.
if len(cc) > 0 {
meta["cc"] = cc
}
@@ -328,6 +327,19 @@ func (m *Manager) SendReply(media []mmodels.Media, senderID int, conversationUUI
if err != nil {
return envelope.NewError(envelope.GeneralError, "Error marshalling message meta", nil)
}
// Generage unique source ID i.e. message-id for email.
inbox, err := m.inboxStore.GetDBRecord(inboxID)
if err != nil {
return err
}
sourceID, err := stringutil.GenerateEmailMessageID(conversationUUID, inbox.From)
if err != nil {
m.lo.Error("error generating source message id", "error", err)
return envelope.NewError(envelope.GeneralError, "Error generating source message id", nil)
}
// Insert Message.
message := models.Message{
ConversationUUID: conversationUUID,
SenderID: senderID,
@@ -339,6 +351,7 @@ func (m *Manager) SendReply(media []mmodels.Media, senderID int, conversationUUI
Private: false,
Media: media,
Meta: string(metaJSON),
SourceID: null.StringFrom(sourceID),
}
return m.InsertMessage(&message)
}
@@ -391,7 +404,7 @@ func (m *Manager) RecordAssigneeUserChange(conversationUUID string, assigneeID i
}
// Assignment to another user.
assignee, err := m.userStore.Get(assigneeID)
assignee, err := m.userStore.GetAgent(assigneeID)
if err != nil {
return err
}
@@ -675,11 +688,8 @@ func (m *Manager) findOrCreateConversation(in *models.Message, inboxID, contactC
conversationUUID string
)
// Search for existing conversation.
sourceIDs := in.References
if in.InReplyTo != "" {
sourceIDs = append(sourceIDs, in.InReplyTo)
}
// Search for existing conversation using the in-reply-to and references.
sourceIDs := append([]string{in.InReplyTo}, in.References...)
conversationID, err = m.findConversationID(sourceIDs)
if err != nil && err != errConversationNotFound {
return new, err

View File

@@ -520,4 +520,7 @@ SET status_id = (SELECT id FROM conversation_statuses WHERE name = 'Open'), snoo
updated_at = now()
WHERE uuid = $1 and status_id in (
SELECT id FROM conversation_statuses WHERE name IN ('Snoozed', 'Closed', 'Resolved')
)
)
-- name: delete-conversation
DELETE FROM conversations WHERE uuid = $1;

View File

@@ -117,6 +117,11 @@ func (e *Email) Send(m models.Message) error {
email.Headers.Set(headerInReplyTo, "<"+m.InReplyTo+">")
}
// Set message id if set.
if m.SourceID.String != "" {
email.Headers.Set(headerMessageID, fmt.Sprintf("<%s>", m.SourceID.String))
}
// Set references message ids
var references string
for _, ref := range m.References {

View File

@@ -6,13 +6,31 @@ import (
"github.com/knadh/stuffbin"
)
// V0_4_0 updates the database schema to V0_4_0.
// V0_4_0 updates the database schema to v0.4.0.
func V0_4_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error {
// Admin role gets the new ai:manage permission, as this user is supposed to have all permissions.
// Admin role gets new permissions.
_, err := db.Exec(`
UPDATE roles
SET permissions = array_append(permissions, 'ai:manage')
WHERE name = 'Admin' AND NOT ('ai:manage' = ANY(permissions));
`)
if err != nil {
return err
}
_, err = db.Exec(`
UPDATE roles
SET permissions = array_append(permissions, 'conversations:write')
WHERE name = 'Admin' AND NOT ('conversations:write' = ANY(permissions));
`)
if err != nil {
return err
}
// Create trigram index on users.email if it doesn't exist.
_, err = db.Exec(`
CREATE INDEX IF NOT EXISTS index_tgrm_users_on_email
ON users USING GIN (email gin_trgm_ops);
`)
return err
}

View File

@@ -16,3 +16,10 @@ type Message struct {
ConversationUUID string `db:"conversation_uuid" json:"conversation_uuid"`
ConversationReferenceNumber string `db:"conversation_reference_number" json:"conversation_reference_number"`
}
type Contact struct {
CreatedAt time.Time `db:"created_at" json:"created_at"`
FirstName string `db:"first_name" json:"first_name"`
LastName string `db:"last_name" json:"last_name"`
Email string `db:"email" json:"email"`
}

View File

@@ -15,4 +15,17 @@ SELECT
m.text_content
FROM conversation_messages m
JOIN conversations c ON m.conversation_id = c.id
WHERE m.type != 'activity' and m.text_content ILIKE '%' || $1 || '%';
WHERE m.type != 'activity' and m.text_content ILIKE '%' || $1 || '%';
-- name: search-contacts
SELECT
id,
created_at,
first_name,
last_name,
email
FROM users
WHERE type = 'contact'
AND deleted_at IS NULL
AND email ILIKE '%' || $1 || '%'
LIMIT 15;

View File

@@ -32,6 +32,7 @@ type Opts struct {
type queries struct {
SearchConversations *sqlx.Stmt `query:"search-conversations"`
SearchMessages *sqlx.Stmt `query:"search-messages"`
SearchContacts *sqlx.Stmt `query:"search-contacts"`
}
// New creates a new search manager
@@ -62,3 +63,13 @@ func (s *Manager) Messages(query string) ([]models.Message, error) {
}
return results, nil
}
// Contacts searches contacts based on the query
func (s *Manager) Contacts(query string) ([]models.Contact, error) {
var results = make([]models.Contact, 0)
if err := s.q.SearchContacts.Select(&results, query); err != nil {
s.lo.Error("error searching contacts", "error", err)
return nil, envelope.NewError(envelope.GeneralError, "Error searching contacts", nil)
}
return results, nil
}

View File

@@ -75,6 +75,7 @@ func (m *Manager) GetAll() (models.Settings, error) {
func (m *Manager) GetAllJSON() (types.JSONText, error) {
var b types.JSONText
if err := m.q.GetAll.Get(&b); err != nil {
m.lo.Error("error fetching settings", "error", err)
return b, err
}
return b, nil
@@ -85,10 +86,12 @@ func (m *Manager) Update(s interface{}) error {
// Marshal settings.
b, err := json.Marshal(s)
if err != nil {
m.lo.Error("error marshalling settings", "error", err)
return envelope.NewError(envelope.GeneralError, "Error updating settings", nil)
}
// Update the settings in the DB.
if _, err := m.q.Update.Exec(b); err != nil {
m.lo.Error("error updating settings", "error", err)
return envelope.NewError(envelope.GeneralError, "Error updating settings", nil)
}
return nil

View File

@@ -3,10 +3,14 @@ package stringutil
import (
"crypto/rand"
"encoding/base64"
"fmt"
"net/mail"
"net/url"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/k3a/html2text"
)
@@ -94,3 +98,46 @@ func RemoveEmpty(s []string) []string {
}
return r
}
// GenerateEmailMessageID generates a RFC-compliant Message-ID for an email.
func GenerateEmailMessageID(messageID string, fromAddress string) (string, error) {
if messageID == "" {
return "", fmt.Errorf("messageID cannot be empty")
}
// Parse from address
addr, err := mail.ParseAddress(fromAddress)
if err != nil {
return "", fmt.Errorf("invalid from address: %w", err)
}
// Extract domain with validation
parts := strings.Split(addr.Address, "@")
if len(parts) != 2 || parts[1] == "" {
return "", fmt.Errorf("invalid domain in from address")
}
domain := parts[1]
// Generate cryptographic random component
random := make([]byte, 8)
if _, err := rand.Read(random); err != nil {
return "", fmt.Errorf("failed to generate random bytes: %w", err)
}
// Sanitize messageID for email Message-ID
cleaner := regexp.MustCompile(`[^\w.-]`) // Allow only alphanum, ., -, _
cleanmessageID := cleaner.ReplaceAllString(messageID, "_")
// Ensure cleaned messageID isn't empty
if cleanmessageID == "" {
return "", fmt.Errorf("messageID became empty after sanitization")
}
// Build RFC-compliant Message-ID
return fmt.Sprintf("%s-%d-%s@%s",
cleanmessageID,
time.Now().UnixNano(), // Nanosecond precision
strings.TrimRight(base64.URLEncoding.EncodeToString(random), "="), // URL-safe base64 without padding
domain,
), nil
}

View File

@@ -45,7 +45,7 @@ FROM users u
LEFT JOIN user_roles ur ON ur.user_id = u.id
LEFT JOIN roles r ON r.id = ur.role_id,
unnest(r.permissions) p
WHERE (u.id = $1 OR u.email = $2) AND u.deleted_at IS NULL AND u.type = 'agent'
WHERE (u.id = $1 OR u.email = $2) AND u.type = $3 AND u.deleted_at IS NULL
GROUP BY u.id;
-- name: set-user-password

View File

@@ -95,7 +95,7 @@ func New(i18n *i18n.I18n, opts Opts) (*Manager, error) {
// VerifyPassword authenticates an user by email and password.
func (u *Manager) VerifyPassword(email string, password []byte) (models.User, error) {
var user models.User
if err := u.q.GetUser.Get(&user, 0, email); err != nil {
if err := u.q.GetUser.Get(&user, 0, email, UserTypeAgent); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return user, envelope.NewError(envelope.InputError, u.i18n.T("user.invalidEmailPassword"), nil)
}
@@ -154,10 +154,25 @@ func (u *Manager) CreateAgent(user *models.User) error {
return nil
}
// GetAgent retrieves an agent by ID.
func (u *Manager) GetAgent(id int) (models.User, error) {
return u.Get(id, UserTypeAgent)
}
// GetAgentByEmail retrieves an agent by email.
func (u *Manager) GetAgentByEmail(email string) (models.User, error) {
return u.GetByEmail(email, UserTypeAgent)
}
// GetContact retrieves a contact by ID.
func (u *Manager) GetContact(id int) (models.User, error) {
return u.Get(id, UserTypeContact)
}
// Get retrieves an user by ID.
func (u *Manager) Get(id int) (models.User, error) {
func (u *Manager) Get(id int, type_ string) (models.User, error) {
var user models.User
if err := u.q.GetUser.Get(&user, id, ""); err != nil {
if err := u.q.GetUser.Get(&user, id, "", type_); err != nil {
if errors.Is(err, sql.ErrNoRows) {
u.lo.Error("user not found", "id", id, "error", err)
return user, envelope.NewError(envelope.GeneralError, "User not found", nil)
@@ -169,9 +184,9 @@ func (u *Manager) Get(id int) (models.User, error) {
}
// GetByEmail retrieves an user by email
func (u *Manager) GetByEmail(email string) (models.User, error) {
func (u *Manager) GetByEmail(email, type_ string) (models.User, error) {
var user models.User
if err := u.q.GetUser.Get(&user, 0, email); err != nil {
if err := u.q.GetUser.Get(&user, 0, email, type_); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return user, envelope.NewError(envelope.GeneralError, "User not found", nil)
}
@@ -183,7 +198,7 @@ func (u *Manager) GetByEmail(email string) (models.User, error) {
// GetSystemUser retrieves the system user.
func (u *Manager) GetSystemUser() (models.User, error) {
return u.GetByEmail(systemUserEmail)
return u.GetByEmail(systemUserEmail, UserTypeAgent)
}
// UpdateAvatar updates the user avatar.
@@ -332,7 +347,6 @@ func (u *Manager) MonitorAgentAvailability(ctx context.Context) {
// markInactiveAgentsOffline sets agents offline if they have been inactive for more than 5 minutes.
func (u *Manager) markInactiveAgentsOffline() {
u.lo.Debug("marking inactive agents offline")
if res, err := u.q.UpdateInactiveOffline.Exec(); err != nil {
u.lo.Error("error setting users offline", "error", err)
} else {
@@ -341,7 +355,6 @@ func (u *Manager) markInactiveAgentsOffline() {
u.lo.Info("set inactive users offline", "count", rows)
}
}
u.lo.Debug("marked inactive agents offline")
}
// verifyPassword compares the provided password with the stored password hash.

View File

@@ -129,6 +129,7 @@ CREATE TABLE users (
);
CREATE UNIQUE INDEX index_unique_users_on_email_and_type_when_deleted_at_is_null ON users (email, type)
WHERE deleted_at IS NULL;
CREATE INDEX index_tgrm_users_on_email ON users USING GIN (email gin_trgm_ops);
DROP TABLE IF EXISTS user_roles CASCADE;
CREATE TABLE user_roles (
@@ -536,7 +537,7 @@ VALUES
(
'Admin',
'Role for users who have complete access to everything.',
'{ai:manage,general_settings:manage,notification_settings:manage,oidc:manage,conversations:read_all,conversations:read_unassigned,conversations:read_assigned,conversations:read_team_inbox,conversations:read,conversations:update_user_assignee,conversations:update_team_assignee,conversations:update_priority,conversations:update_status,conversations:update_tags,messages:read,messages:write,view:manage,status:manage,tags:manage,macros:manage,users:manage,teams:manage,automations:manage,inboxes:manage,roles:manage,reports:manage,templates:manage,business_hours:manage,sla:manage}'
'{conversations:write,ai:manage,general_settings:manage,notification_settings:manage,oidc:manage,conversations:read_all,conversations:read_unassigned,conversations:read_assigned,conversations:read_team_inbox,conversations:read,conversations:update_user_assignee,conversations:update_team_assignee,conversations:update_priority,conversations:update_status,conversations:update_tags,messages:read,messages:write,view:manage,status:manage,tags:manage,macros:manage,users:manage,teams:manage,automations:manage,inboxes:manage,roles:manage,reports:manage,templates:manage,business_hours:manage,sla:manage}'
);

View File

@@ -108,6 +108,7 @@
{{ define "footer" }}
</div>
<div class="footer">
<span style="opacity: 0.6;">Powered by <a href="https://libredesk.io/" target="_blank">Libredesk</a></span>
</div>
<div class="gutter">&nbsp;</div>
</body>