package main import ( "slices" "strings" amodels "github.com/abhinavxd/libredesk/internal/auth/models" authzModels "github.com/abhinavxd/libredesk/internal/authz/models" cmodels "github.com/abhinavxd/libredesk/internal/conversation/models" "github.com/abhinavxd/libredesk/internal/envelope" umodels "github.com/abhinavxd/libredesk/internal/user/models" "github.com/valyala/fasthttp" "github.com/zerodha/fastglue" ) 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"` SenderType string `json:"sender_type"` Mentions []cmodels.MentionInput `json:"mentions"` EchoID string `json:"echo_id"` SourceID string `json:"source_id"` // RFC 5322 Message-ID of the inbound message; stored on the created contact message so replies thread on it. Contact sender only. } // handleGetMessages returns messages for a conversation. func handleGetMessages(r *fastglue.Request) error { var ( app = r.Context.(*App) uuid = r.RequestCtx.UserValue("uuid").(string) auser = r.RequestCtx.UserValue("user").(amodels.User) total = 0 private *bool ) page, pageSize := getPagination(r) // Parse optional private filter (null = no filter) if r.RequestCtx.QueryArgs().Has("private") { p := r.RequestCtx.QueryArgs().GetBool("private") private = &p } // Parse repeated type params: ?type=incoming&type=outgoing var msgTypes []string for _, v := range r.RequestCtx.QueryArgs().PeekMulti("type") { msgTypes = append(msgTypes, string(v)) } user, err := app.user.GetAgentCachedOrLoad(auser.ID) if err != nil { return sendErrorEnvelope(r, err) } // Check permission _, err = enforceConversationAccess(app, uuid, user) if err != nil { return sendErrorEnvelope(r, err) } messages, pageSize, err := app.conversation.GetConversationMessages(uuid, page, pageSize, private, msgTypes) if err != nil { return sendErrorEnvelope(r, err) } rootURL, _ := app.setting.GetAppRootURL() for i := range messages { total = messages[i].Total app.conversation.SignAttachmentURLs(messages[i].Attachments) resolveQuotedCIDs(app, &messages[i]) resolveAttachmentCIDs(&messages[i], rootURL) } // Process CSAT status for all messages (will only affect CSAT messages) app.conversation.ProcessCSATStatus(messages) // Strip CSAT UUID from agent sessions to prevent self-rating. if r.RequestCtx.UserValue("auth_method") != authMethodAPIKey { for i := range messages { if messages[i].HasCSAT() { messages[i].StripCSATUUID() } } } return r.SendEnvelope(envelope.PageResults{ Total: total, Results: messages, Page: page, PerPage: pageSize, TotalPages: (total + pageSize - 1) / pageSize, }) } // handleGetMessage fetches a single from DB using the uuid. func handleGetMessage(r *fastglue.Request) error { var ( app = r.Context.(*App) uuid = r.RequestCtx.UserValue("uuid").(string) cuuid = r.RequestCtx.UserValue("cuuid").(string) auser = r.RequestCtx.UserValue("user").(amodels.User) ) user, err := app.user.GetAgentCachedOrLoad(auser.ID) if err != nil { return sendErrorEnvelope(r, err) } // Check permission _, err = enforceConversationAccess(app, cuuid, user) if err != nil { return sendErrorEnvelope(r, err) } message, err := app.conversation.GetMessage(uuid) if err != nil { return sendErrorEnvelope(r, err) } if message.ConversationUUID != cuuid { return sendErrorEnvelope(r, envelope.NewError(envelope.PermissionError, "Permission denied", nil)) } // Process CSAT status for the message (will only affect CSAT messages) messages := []cmodels.Message{message} app.conversation.ProcessCSATStatus(messages) message = messages[0] // Strip CSAT UUID from agent sessions to prevent self-rating. if r.RequestCtx.UserValue("auth_method") != authMethodAPIKey && message.HasCSAT() { message.StripCSATUUID() } rootURL, _ := app.setting.GetAppRootURL() app.conversation.SignAttachmentURLs(message.Attachments) resolveQuotedCIDs(app, &message) resolveAttachmentCIDs(&message, rootURL) return r.SendEnvelope(message) } // handleRetryMessage changes message status to `pending`, so it's enqueued for sending. func handleRetryMessage(r *fastglue.Request) error { var ( app = r.Context.(*App) uuid = r.RequestCtx.UserValue("uuid").(string) cuuid = r.RequestCtx.UserValue("cuuid").(string) auser = r.RequestCtx.UserValue("user").(amodels.User) ) user, err := app.user.GetAgentCachedOrLoad(auser.ID) if err != nil { return sendErrorEnvelope(r, err) } // Check permission _, err = enforceConversationAccess(app, cuuid, user) if err != nil { return sendErrorEnvelope(r, err) } // Only outgoing agent messages that have failed can be retried. msg, err := app.conversation.GetMessage(uuid) if err != nil { return sendErrorEnvelope(r, err) } if msg.SenderType != cmodels.SenderTypeAgent || msg.Status != cmodels.MessageStatusFailed || msg.SenderID != user.ID || msg.ConversationUUID != cuuid { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.badRequest"), nil, envelope.InputError) } if err = app.conversation.MarkMessageAsPending(uuid); err != nil { return sendErrorEnvelope(r, err) } return r.SendEnvelope(true) } // handleSendMessage sends a message in a conversation. func handleSendMessage(r *fastglue.Request) error { var ( app = r.Context.(*App) auser = r.RequestCtx.UserValue("user").(amodels.User) cuuid = r.RequestCtx.UserValue("cuuid").(string) req = messageReq{} ) user, err := app.user.GetAgentCachedOrLoad(auser.ID) if err != nil { return sendErrorEnvelope(r, err) } // Check access to conversation. conv, err := enforceConversationAccess(app, cuuid, user) if err != nil { return sendErrorEnvelope(r, err) } if err := r.Decode(&req, "json"); err != nil { app.lo.Error("error unmarshalling message request", "error", err) return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("errors.parsingRequest"), nil, envelope.InputError) } if !canCreateConversationMessage(user, req) { return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.T("status.deniedPermission"), nil, envelope.PermissionError) } // Make sure the inbox is enabled. inbox, err := app.inbox.GetDBRecord(conv.InboxID) if err != nil { return sendErrorEnvelope(r, err) } if !inbox.Enabled { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("status.disabledInbox"), nil, envelope.InputError) } if req.SenderType != umodels.UserTypeAgent && req.SenderType != umodels.UserTypeContact { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.InputError) } // Contacts cannot send private messages if req.SenderType == umodels.UserTypeContact && req.Private { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.badRequest"), nil, envelope.InputError) } // Check if user has permission to send messages as contact if req.SenderType == umodels.UserTypeContact { parts := strings.Split(authzModels.PermMessagesWriteAsContact, ":") if len(parts) != 2 { app.lo.Error("error parsing permission string", "permission", authzModels.PermMessagesWriteAsContact) return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.T("globals.messages.somethingWentWrong"), nil)) } ok, err := app.authz.Enforce(user, parts[0], parts[1]) if err != nil { app.lo.Error("error checking permission", "error", err) return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.T("globals.messages.somethingWentWrong"), nil)) } if !ok { return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.T("status.deniedPermission"), nil, envelope.PermissionError) } } // Get media for all attachments, skip any already associated with a model. media, err := getUnassociatedMedia(app, req.Attachments) if err != nil { return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.GeneralError) } rootURL, _ := app.setting.GetAppRootURL() // Create contact message. if req.SenderType == umodels.UserTypeContact { message, err := app.conversation.CreateContactMessage(media, int(conv.ContactID), cuuid, req.Message, cmodels.ContentTypeHTML, false, req.SourceID) if err != nil { return sendErrorEnvelope(r, err) } resolveQuotedCIDs(app, &message) resolveAttachmentCIDs(&message, rootURL) return r.SendEnvelope(message) } // Send private note. if req.Private { message, err := app.conversation.SendPrivateNote(media, user.ID, cuuid, req.Message, req.Mentions) if err != nil { return sendErrorEnvelope(r, err) } resolveAttachmentCIDs(&message, rootURL) return r.SendEnvelope(message) } // Queue outgoing reply. meta := map[string]any{} if req.EchoID != "" { meta["echo_id"] = req.EchoID } message, err := app.conversation.QueueReply(media, conv.InboxID, user.ID, conv.ContactID, cuuid, req.Message, req.To, req.CC, req.BCC, meta) if err != nil { return sendErrorEnvelope(r, err) } markAssignmentNotificationRead(app, conv, user) resolveQuotedCIDs(app, &message) resolveAttachmentCIDs(&message, rootURL) return r.SendEnvelope(message) } // resolveAttachmentCIDs replaces inline image cid: references in email message content // with actual attachment URLs and resolves relative /uploads/ paths to absolute URLs. func resolveAttachmentCIDs(msg *cmodels.Message, rootURL string) { for _, att := range msg.Attachments { if att.ContentID != "" && att.URL != "" { msg.Content = strings.ReplaceAll(msg.Content, "cid:"+att.ContentID, att.URL) } } if rootURL != "" { msg.Content = strings.ReplaceAll(msg.Content, `src="/uploads/`, `src="`+rootURL+`/uploads/`) msg.Content = strings.ReplaceAll(msg.Content, `src='/uploads/`, `src='`+rootURL+`/uploads/`) } } // resolveQuotedCIDs replaces cid: refs to media on other messages with signed URLs. func resolveQuotedCIDs(app *App, msg *cmodels.Message) { refs, err := app.conversation.GetInlineMediaRefs(msg) if err != nil { app.lo.Error("error fetching inline media refs", "conversation_uuid", msg.ConversationUUID, "error", err) return } for _, ref := range refs { url := app.media.GetURL(ref.UUID, ref.ContentType, ref.Filename) msg.Content = strings.ReplaceAll(msg.Content, "cid:"+ref.ContentID, url) } } // canCreateConversationMessage returns whether the user may create a message of the requested visibility. func canCreateConversationMessage(user umodels.User, req messageReq) bool { requiredPermission := authzModels.PermMessagesWrite if req.Private { requiredPermission = authzModels.PermMessagesWritePrivate } return slices.Contains(user.Permissions, requiredPermission) }