diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..dd334a99 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,22 @@ +# Contributing to Libredesk + +Thanks for your interest in contributing. + +## Contribution Guidelines + +- **All PRs must align with the project’s scope, goals, and technical direction.** +- **Large or non-trivial PRs should be discussed first via an issue.** +- Bug fixes and documentation improvements do **not** require prior discussion. +- Please keep PRs focused on a single concern where possible. + +## Process + +- **Propose first (for large changes)** + Open an issue describing what you want to build, why it fits Libredesk, + and the high-level implementation approach. + +- **Keep PRs small** + Smaller, well-scoped PRs are easier to review and test. + Bundling multiple features into a single PR is discouraged. + +Thanks for helping keep Libredesk focused and maintainable. diff --git a/Dockerfile b/Dockerfile index 0890e859..7b0065d7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,4 @@ -# Use the latest version of Alpine Linux as the base image -FROM alpine:latest +FROM alpine:3.18 # Install necessary packages RUN apk --no-cache add ca-certificates tzdata diff --git a/Makefile b/Makefile index 871a4068..1ff1eb4d 100644 --- a/Makefile +++ b/Makefile @@ -105,5 +105,7 @@ demo-build: # Run tests. .PHONY: test test: - @echo "→ Running tests..." + @echo "→ Running Go tests..." go test -count=1 ./... + @echo "→ Running frontend tests..." + cd ${FRONTEND_DIR} && npx pnpm install --frozen-lockfile && npx pnpm test:run diff --git a/README.md b/README.md index a2ef0ec4..8d61540e 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Modern, open source, self-hosted customer support desk. Single binary app. -![image](https://libredesk.io/hero.png) +![image](https://libredesk.io/hero.png?v=1) Visit [libredesk.io](https://libredesk.io) for more info. Check out the [**Live demo**](https://demo.libredesk.io/). @@ -79,9 +79,14 @@ __________________ See [installation docs](https://docs.libredesk.io/getting-started/installation) __________________ - ## Developers -If you are interested in contributing, refer to the [developer setup](https://docs.libredesk.io/contributing/developer-setup). The backend is written in Go and the frontend is Vue js 3 with Shadcn for UI components. + +- If you are interested in contributing, please read [CONTRIBUTING.md](./CONTRIBUTING.md) first. +- For local development and setup, refer to the [developer setup](https://docs.libredesk.io/contributing/developer-setup). +- For planned features and project direction, see [ROADMAP.md](./ROADMAP.md). + +The backend is written in Go and the frontend is Vue.js 3 with Shadcn UI. + ## Translators diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 00000000..1fde191e --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,16 @@ +# Libredesk Roadmap + +## Vision +A high-performance, omni-channel, self-hosted customer support desk. + +## Near Term +- OAuth inbox support for Microsoft accounts - Done +- Draft saving support for conversations - Done +- Support for mentions - WIP +- Ability to import agents in bulk - WIP + +## Mid Term +- Full-fledged live chat widget - WIP + +## Long Term +- WhatsApp channel - TODO diff --git a/cmd/actvity_log.go b/cmd/actvity_log.go index 17057a14..efbf1670 100644 --- a/cmd/actvity_log.go +++ b/cmd/actvity_log.go @@ -1,8 +1,6 @@ package main import ( - "strconv" - "github.com/abhinavxd/libredesk/internal/envelope" "github.com/zerodha/fastglue" ) @@ -10,14 +8,13 @@ import ( // handleGetActivityLogs returns activity logs from the database. func handleGetActivityLogs(r *fastglue.Request) error { var ( - app = r.Context.(*App) - order = string(r.RequestCtx.QueryArgs().Peek("order")) - orderBy = string(r.RequestCtx.QueryArgs().Peek("order_by")) - filters = string(r.RequestCtx.QueryArgs().Peek("filters")) - page, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page"))) - pageSize, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page_size"))) - total = 0 + app = r.Context.(*App) + order = string(r.RequestCtx.QueryArgs().Peek("order")) + orderBy = string(r.RequestCtx.QueryArgs().Peek("order_by")) + filters = string(r.RequestCtx.QueryArgs().Peek("filters")) + total = 0 ) + page, pageSize := getPagination(r) logs, err := app.activityLog.GetAll(order, orderBy, filters, page, pageSize) if err != nil { return sendErrorEnvelope(r, err) diff --git a/cmd/agent_import.go b/cmd/agent_import.go new file mode 100644 index 00000000..ea8f9d21 --- /dev/null +++ b/cmd/agent_import.go @@ -0,0 +1,229 @@ +package main + +import ( + "encoding/csv" + "fmt" + "strings" + + "github.com/abhinavxd/libredesk/internal/envelope" + "github.com/abhinavxd/libredesk/internal/stringutil" + "github.com/valyala/fasthttp" + "github.com/zerodha/fastglue" +) + +// handleImportAgents handles CSV upload and starts import job +func handleImportAgents(r *fastglue.Request) error { + var app = r.Context.(*App) + + file, err := r.RequestCtx.FormFile("file") + if err != nil { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "{globals.terms.file}"), nil, envelope.InputError) + } + + fileContent, err := file.Open() + if err != nil { + app.lo.Error("error opening uploaded file", "error", err) + return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorReading", "name", "{globals.terms.file}"), nil, envelope.GeneralError) + } + defer fileContent.Close() + + reader := csv.NewReader(fileContent) + reader.TrimLeadingSpace = true + records, err := reader.ReadAll() + if err != nil { + app.lo.Error("error parsing CSV", "error", err) + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "{globals.terms.csvFile}"), nil, envelope.InputError) + } + + if len(records) < 2 { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("importer.csvMustContainHeadersAndData"), nil, envelope.InputError) + } + + err = app.importer.Submit("agents", func() error { + return processAgentImport(app, records) + }) + + if err != nil { + return r.SendErrorEnvelope(fasthttp.StatusConflict, app.i18n.T("importer.importAlreadyInProgress"), nil, envelope.GeneralError) + } + + return r.SendEnvelope(true) +} + +// handleGetAgentImportStatus returns current import status +func handleGetAgentImportStatus(r *fastglue.Request) error { + var app = r.Context.(*App) + status, err := app.importer.GetStatus("agents") + if err != nil { + return sendErrorEnvelope(r, err) + } + return r.SendEnvelope(status) +} + +func processAgentImport(app *App, records [][]string) error { + // Parse headers + headerMap := make(map[string]int) + for i, h := range records[0] { + headerMap[strings.TrimSpace(strings.ToLower(h))] = i + } + + // Validate required columns + required := []string{"first_name", "last_name", "email", "roles"} + for _, col := range required { + if _, ok := headerMap[col]; !ok { + return fmt.Errorf("missing required column: %s", col) + } + } + + // Fetch valid teams and roles once + allTeams, err := app.team.GetAll() + if err != nil { + return fmt.Errorf("failed to fetch teams: %v", err) + } + + allRoles, err := app.role.GetAll() + if err != nil { + return fmt.Errorf("failed to fetch roles: %v", err) + } + + validTeams := make(map[string]bool) + for _, t := range allTeams { + validTeams[t.Name] = true + } + + validRoles := make(map[string]bool) + for _, r := range allRoles { + validRoles[r.Name] = true + } + + // Initialize import + total := len(records) - 1 + app.importer.UpdateCounts("agents", total, 0, 0) + app.importer.AddLog("agents", fmt.Sprintf("Starting import of %d agents", total)) + + // Process each row + for i, record := range records[1:] { + rowNum := i + 1 + + // Parse fields + firstName := getField(record, headerMap, "first_name") + lastName := getField(record, headerMap, "last_name") + email := strings.TrimSpace(strings.ToLower(getField(record, headerMap, "email"))) + rolesStr := getField(record, headerMap, "roles") + teamsStr := getField(record, headerMap, "teams") + + // Validate required fields + var missing []string + if firstName == "" { + missing = append(missing, "first_name") + } + if lastName == "" { + missing = append(missing, "last_name") + } + if email == "" { + missing = append(missing, "email") + } + if rolesStr == "" { + missing = append(missing, "roles") + } + if len(missing) > 0 { + app.importer.UpdateCounts("agents", 0, 0, 1) + app.importer.AddLog("agents", fmt.Sprintf("Row %d: Error - missing required fields: %s", rowNum, strings.Join(missing, ", "))) + continue + } + + // Validate email format + if !stringutil.ValidEmail(email) { + app.importer.UpdateCounts("agents", 0, 0, 1) + app.importer.AddLog("agents", fmt.Sprintf("Row %d: Error - invalid email format: %s", rowNum, email)) + continue + } + + // Parse and validate roles + roles := parseList(rolesStr) + if len(roles) == 0 { + app.importer.UpdateCounts("agents", 0, 0, 1) + app.importer.AddLog("agents", fmt.Sprintf("Row %d (%s): Error - at least one role required", rowNum, email)) + continue + } + + invalidRoles := findInvalid(roles, validRoles) + if len(invalidRoles) > 0 { + app.importer.UpdateCounts("agents", 0, 0, 1) + app.importer.AddLog("agents", fmt.Sprintf("Row %d (%s): Error - invalid role(s): %s", rowNum, email, strings.Join(invalidRoles, ", "))) + continue + } + + // Parse and validate teams (optional) + teams := parseList(teamsStr) + if len(teams) > 0 { + invalidTeams := findInvalid(teams, validTeams) + if len(invalidTeams) > 0 { + app.importer.UpdateCounts("agents", 0, 0, 1) + app.importer.AddLog("agents", fmt.Sprintf("Row %d (%s): Error - invalid team(s): %s", rowNum, email, strings.Join(invalidTeams, ", "))) + continue + } + } + + // Check if agent already exists + if _, err := app.user.GetAgent(0, email); err == nil { + app.importer.UpdateCounts("agents", 0, 0, 1) + app.importer.AddLog("agents", fmt.Sprintf("Row %d (%s): Error - email already exists", rowNum, email)) + continue + } + + // Create agent + agent, err := app.user.CreateAgent(firstName, lastName, email, roles) + if err != nil { + app.importer.UpdateCounts("agents", 0, 0, 1) + app.importer.AddLog("agents", fmt.Sprintf("Row %d (%s): Error - failed to create agent: %v", rowNum, email, err)) + continue + } + + // Assign teams (if provided) + if len(teams) > 0 { + if err := app.team.UpsertUserTeams(agent.ID, teams); err != nil { + app.importer.UpdateCounts("agents", 0, 0, 1) + app.importer.AddLog("agents", fmt.Sprintf("Row %d (%s): Error - team assignment failed: %v", rowNum, email, err)) + continue + } + } + + app.importer.UpdateCounts("agents", 0, 1, 0) + app.importer.AddLog("agents", fmt.Sprintf("Row %d: Created agent %s (%s)", rowNum, agent.FullName(), agent.Email.String)) + } + + // Final summary + status, _ := app.importer.GetStatus("agents") + app.importer.AddLog("agents", fmt.Sprintf("Import completed: %d of %d successful, %d failed", + status.Success, status.Total, status.Errors)) + + return nil +} + +func getField(record []string, headerMap map[string]int, name string) string { + if idx, ok := headerMap[name]; ok && idx < len(record) { + return strings.TrimSpace(record[idx]) + } + return "" +} + +func parseList(s string) []string { + var result []string + for part := range strings.SplitSeq(s, ",") { + if trimmed := strings.TrimSpace(part); trimmed != "" { + result = append(result, trimmed) + } + } + return result +} + +func findInvalid(items []string, validMap map[string]bool) []string { + var invalid []string + for _, item := range items { + if !validMap[item] { + invalid = append(invalid, item) + } + } + return invalid +} diff --git a/cmd/auth.go b/cmd/auth.go index e075202f..998275a1 100644 --- a/cmd/auth.go +++ b/cmd/auth.go @@ -13,6 +13,7 @@ import ( var ( oidcStateSessKey = "oidc_state" + oidcNextSessKey = "oidc_next" ) // handleOIDCLogin redirects to the OIDC provider for login. @@ -33,9 +34,13 @@ func handleOIDCLogin(r *fastglue.Request) error { return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorGenerating", "name", "state"), nil, envelope.GeneralError) } - if err = app.auth.SetSessionValues(r, map[string]interface{}{ + sessionValues := map[string]any{ oidcStateSessKey: state, - }); err != nil { + // For redirecting after login + oidcNextSessKey: string(r.RequestCtx.QueryArgs().Peek("next")), + } + + if err = app.auth.SetSessionValues(r, sessionValues); err != nil { app.lo.Error("error saving state in session", "error", err) return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorSaving", "name", "{globals.terms.session}"), nil, envelope.GeneralError) } @@ -104,5 +109,12 @@ func handleOIDCCallback(r *fastglue.Request) error { app.lo.Error("error creating login activity log", "error", err) } - return r.Redirect("/", fasthttp.StatusFound, nil, "") + // Read the 'next' parameter from session to redirect after login. + nextParam, _ := app.auth.GetSessionValue(r, oidcNextSessKey) + redirectURL := "/" + if nextStr, ok := nextParam.(string); ok && nextStr != "" { + redirectURL = nextStr + } + + return r.RedirectURI(redirectURL, fasthttp.StatusFound, nil, "") } diff --git a/cmd/config.go b/cmd/config.go index d830295d..9f9ae460 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -48,7 +48,7 @@ func handleGetConfig(r *fastglue.Request) error { "provider": provider.Provider, "provider_url": provider.ProviderURL, "client_id": provider.ClientID, - "logo_url": provider.ProviderLogoURL, + "logo_url": provider.LogoURL, "enabled": provider.Enabled, "redirect_uri": provider.RedirectURI, } diff --git a/cmd/contacts.go b/cmd/contacts.go index 31b8d2c5..281f9df4 100644 --- a/cmd/contacts.go +++ b/cmd/contacts.go @@ -25,14 +25,13 @@ type blockContactReq struct { // handleGetContacts returns a list of contacts from the database. func handleGetContacts(r *fastglue.Request) error { var ( - app = r.Context.(*App) - order = string(r.RequestCtx.QueryArgs().Peek("order")) - orderBy = string(r.RequestCtx.QueryArgs().Peek("order_by")) - filters = string(r.RequestCtx.QueryArgs().Peek("filters")) - page, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page"))) - pageSize, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page_size"))) - total = 0 + app = r.Context.(*App) + order = string(r.RequestCtx.QueryArgs().Peek("order")) + orderBy = string(r.RequestCtx.QueryArgs().Peek("order_by")) + filters = string(r.RequestCtx.QueryArgs().Peek("filters")) + total = 0 ) + page, pageSize := getPagination(r) contacts, err := app.user.GetContacts(page, pageSize, order, orderBy, filters) if err != nil { return sendErrorEnvelope(r, err) diff --git a/cmd/conversation.go b/cmd/conversation.go index 80840ceb..8478a130 100644 --- a/cmd/conversation.go +++ b/cmd/conversation.go @@ -1,6 +1,7 @@ package main import ( + "slices" "strconv" "time" @@ -12,6 +13,7 @@ import ( medModels "github.com/abhinavxd/libredesk/internal/media/models" "github.com/abhinavxd/libredesk/internal/stringutil" umodels "github.com/abhinavxd/libredesk/internal/user/models" + vmodels "github.com/abhinavxd/libredesk/internal/view/models" wmodels "github.com/abhinavxd/libredesk/internal/webhook/models" "github.com/valyala/fasthttp" "github.com/volatiletech/null/v9" @@ -55,16 +57,16 @@ type createConversationRequest struct { // handleGetAllConversations retrieves all conversations. func handleGetAllConversations(r *fastglue.Request) error { var ( - app = r.Context.(*App) - order = string(r.RequestCtx.QueryArgs().Peek("order")) - orderBy = string(r.RequestCtx.QueryArgs().Peek("order_by")) - page, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page"))) - pageSize, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page_size"))) - filters = string(r.RequestCtx.QueryArgs().Peek("filters")) - total = 0 + app = r.Context.(*App) + user = r.RequestCtx.UserValue("user").(amodels.User) + order = string(r.RequestCtx.QueryArgs().Peek("order")) + orderBy = string(r.RequestCtx.QueryArgs().Peek("order_by")) + filters = string(r.RequestCtx.QueryArgs().Peek("filters")) + total = 0 ) + page, pageSize := getPagination(r) - conversations, err := app.conversation.GetAllConversationsList(order, orderBy, filters, page, pageSize) + conversations, err := app.conversation.GetAllConversationsList(user.ID, order, orderBy, filters, page, pageSize) if err != nil { return sendErrorEnvelope(r, err) } @@ -85,16 +87,15 @@ func handleGetAllConversations(r *fastglue.Request) error { // handleGetAssignedConversations retrieves conversations assigned to the current user. func handleGetAssignedConversations(r *fastglue.Request) error { var ( - app = r.Context.(*App) - user = r.RequestCtx.UserValue("user").(amodels.User) - order = string(r.RequestCtx.QueryArgs().Peek("order")) - orderBy = string(r.RequestCtx.QueryArgs().Peek("order_by")) - filters = string(r.RequestCtx.QueryArgs().Peek("filters")) - page, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page"))) - pageSize, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page_size"))) - total = 0 + app = r.Context.(*App) + user = r.RequestCtx.UserValue("user").(amodels.User) + order = string(r.RequestCtx.QueryArgs().Peek("order")) + orderBy = string(r.RequestCtx.QueryArgs().Peek("order_by")) + filters = string(r.RequestCtx.QueryArgs().Peek("filters")) + total = 0 ) - conversations, err := app.conversation.GetAssignedConversationsList(user.ID, order, orderBy, filters, page, pageSize) + page, pageSize := getPagination(r) + conversations, err := app.conversation.GetAssignedConversationsList(user.ID, user.ID, order, orderBy, filters, page, pageSize) if err != nil { return sendErrorEnvelope(r, err) } @@ -114,16 +115,45 @@ func handleGetAssignedConversations(r *fastglue.Request) error { // handleGetUnassignedConversations retrieves unassigned conversations. func handleGetUnassignedConversations(r *fastglue.Request) error { var ( - app = r.Context.(*App) - order = string(r.RequestCtx.QueryArgs().Peek("order")) - orderBy = string(r.RequestCtx.QueryArgs().Peek("order_by")) - filters = string(r.RequestCtx.QueryArgs().Peek("filters")) - page, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page"))) - pageSize, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page_size"))) - total = 0 + app = r.Context.(*App) + user = r.RequestCtx.UserValue("user").(amodels.User) + order = string(r.RequestCtx.QueryArgs().Peek("order")) + orderBy = string(r.RequestCtx.QueryArgs().Peek("order_by")) + filters = string(r.RequestCtx.QueryArgs().Peek("filters")) + total = 0 ) + page, pageSize := getPagination(r) - conversations, err := app.conversation.GetUnassignedConversationsList(order, orderBy, filters, page, pageSize) + conversations, err := app.conversation.GetUnassignedConversationsList(user.ID, order, orderBy, filters, page, pageSize) + if err != nil { + return sendErrorEnvelope(r, err) + } + if len(conversations) > 0 { + total = conversations[0].Total + } + + return r.SendEnvelope(envelope.PageResults{ + Results: conversations, + Total: total, + PerPage: pageSize, + TotalPages: (total + pageSize - 1) / pageSize, + Page: page, + }) +} + +// handleGetMentionedConversations retrieves conversations where the current user is mentioned. +func handleGetMentionedConversations(r *fastglue.Request) error { + var ( + app = r.Context.(*App) + user = r.RequestCtx.UserValue("user").(amodels.User) + order = string(r.RequestCtx.QueryArgs().Peek("order")) + orderBy = string(r.RequestCtx.QueryArgs().Peek("order_by")) + filters = string(r.RequestCtx.QueryArgs().Peek("filters")) + total = 0 + ) + page, pageSize := getPagination(r) + + conversations, err := app.conversation.GetMentionedConversationsList(user.ID, order, orderBy, filters, page, pageSize) if err != nil { return sendErrorEnvelope(r, err) } @@ -143,15 +173,14 @@ func handleGetUnassignedConversations(r *fastglue.Request) error { // handleGetViewConversations retrieves conversations for a view. func handleGetViewConversations(r *fastglue.Request) error { var ( - app = r.Context.(*App) - auser = r.RequestCtx.UserValue("user").(amodels.User) - viewID, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string)) - order = string(r.RequestCtx.QueryArgs().Peek("order")) - orderBy = string(r.RequestCtx.QueryArgs().Peek("order_by")) - page, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page"))) - pageSize, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page_size"))) - total = 0 + app = r.Context.(*App) + auser = r.RequestCtx.UserValue("user").(amodels.User) + viewID, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string)) + order = string(r.RequestCtx.QueryArgs().Peek("order")) + orderBy = string(r.RequestCtx.QueryArgs().Peek("order_by")) + total = 0 ) + page, pageSize := getPagination(r) if viewID < 1 { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`view_id`"), nil, envelope.InputError) } @@ -161,17 +190,31 @@ func handleGetViewConversations(r *fastglue.Request) error { if err != nil { return sendErrorEnvelope(r, err) } - if view.UserID != auser.ID { - return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.T("conversation.viewPermissionDenied"), nil, envelope.PermissionError) - } user, err := app.user.GetAgent(auser.ID, "") if err != nil { return sendErrorEnvelope(r, err) } + hasAccess := false + switch view.Visibility { + case vmodels.VisibilityUser: + hasAccess = view.UserID != nil && *view.UserID == auser.ID + case vmodels.VisibilityAll: + hasAccess = true + case vmodels.VisibilityTeam: + if view.TeamID != nil { + hasAccess = slices.Contains(user.Teams.IDs(), *view.TeamID) + } + } + + if !hasAccess { + return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.T("conversation.viewPermissionDenied"), nil, envelope.PermissionError) + } + // Prepare lists user has access to based on user permissions, internally this prepares the SQL query. lists := []string{} + hasTeamAll := slices.Contains(user.Permissions, authzModels.PermConversationsReadTeamAll) for _, perm := range user.Permissions { if perm == authzModels.PermConversationsReadAll { // No further lists required as user has access to all conversations. @@ -184,9 +227,13 @@ func handleGetViewConversations(r *fastglue.Request) error { if perm == authzModels.PermConversationsReadAssigned { lists = append(lists, cmodels.AssignedConversations) } - if perm == authzModels.PermConversationsReadTeamInbox { + // Skip TeamUnassignedConversations if user has TeamAllConversations (superset). + if perm == authzModels.PermConversationsReadTeamInbox && !hasTeamAll { lists = append(lists, cmodels.TeamUnassignedConversations) } + if perm == authzModels.PermConversationsReadTeamAll { + lists = append(lists, cmodels.TeamAllConversations) + } } // No lists found, user doesn't have access to any conversations. @@ -194,7 +241,7 @@ func handleGetViewConversations(r *fastglue.Request) error { return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.Ts("globals.messages.denied", "name", "{globals.terms.permission}"), nil, envelope.PermissionError) } - conversations, err := app.conversation.GetViewConversationsList(user.ID, user.Teams.IDs(), lists, order, orderBy, string(view.Filters), page, pageSize) + conversations, err := app.conversation.GetViewConversationsList(user.ID, user.ID, user.Teams.IDs(), lists, order, orderBy, string(view.Filters), page, pageSize) if err != nil { return sendErrorEnvelope(r, err) } @@ -214,16 +261,15 @@ func handleGetViewConversations(r *fastglue.Request) error { // handleGetTeamUnassignedConversations returns conversations assigned to a team but not to any user. func handleGetTeamUnassignedConversations(r *fastglue.Request) error { var ( - app = r.Context.(*App) - auser = r.RequestCtx.UserValue("user").(amodels.User) - teamIDStr = r.RequestCtx.UserValue("id").(string) - order = string(r.RequestCtx.QueryArgs().Peek("order")) - orderBy = string(r.RequestCtx.QueryArgs().Peek("order_by")) - filters = string(r.RequestCtx.QueryArgs().Peek("filters")) - page, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page"))) - pageSize, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page_size"))) - total = 0 + app = r.Context.(*App) + auser = r.RequestCtx.UserValue("user").(amodels.User) + teamIDStr = r.RequestCtx.UserValue("id").(string) + order = string(r.RequestCtx.QueryArgs().Peek("order")) + orderBy = string(r.RequestCtx.QueryArgs().Peek("order_by")) + filters = string(r.RequestCtx.QueryArgs().Peek("filters")) + total = 0 ) + page, pageSize := getPagination(r) teamID, _ := strconv.Atoi(teamIDStr) if teamID < 1 { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`team_id`"), nil, envelope.InputError) @@ -239,7 +285,7 @@ func handleGetTeamUnassignedConversations(r *fastglue.Request) error { return sendErrorEnvelope(r, envelope.NewError(envelope.PermissionError, app.i18n.T("conversation.notMemberOfTeam"), nil)) } - conversations, err := app.conversation.GetTeamUnassignedConversationsList(teamID, order, orderBy, filters, page, pageSize) + conversations, err := app.conversation.GetTeamUnassignedConversationsList(auser.ID, teamID, order, orderBy, filters, page, pageSize) if err != nil { return sendErrorEnvelope(r, err) } @@ -279,7 +325,7 @@ func handleGetConversation(r *fastglue.Request) error { return r.SendEnvelope(conv) } -// handleUpdateConversationAssigneeLastSeen updates the assignee's last seen timestamp for a conversation. +// handleUpdateConversationAssigneeLastSeen updates the current user's last seen timestamp for a conversation. func handleUpdateConversationAssigneeLastSeen(r *fastglue.Request) error { var ( app = r.Context.(*App) @@ -294,7 +340,30 @@ func handleUpdateConversationAssigneeLastSeen(r *fastglue.Request) error { if err != nil { return sendErrorEnvelope(r, err) } - if err = app.conversation.UpdateConversationAssigneeLastSeen(uuid); err != nil { + + if err = app.conversation.UpdateUserLastSeen(uuid, auser.ID); err != nil { + return sendErrorEnvelope(r, err) + } + return r.SendEnvelope(true) +} + +// handleMarkConversationAsUnread marks a conversation as unread for the current user. +func handleMarkConversationAsUnread(r *fastglue.Request) error { + var ( + app = r.Context.(*App) + uuid = r.RequestCtx.UserValue("uuid").(string) + auser = r.RequestCtx.UserValue("user").(amodels.User) + ) + user, err := app.user.GetAgent(auser.ID, "") + if err != nil { + return sendErrorEnvelope(r, err) + } + _, err = enforceConversationAccess(app, uuid, user) + if err != nil { + return sendErrorEnvelope(r, err) + } + + if err = app.conversation.MarkAsUnread(uuid, auser.ID); err != nil { return sendErrorEnvelope(r, err) } return r.SendEnvelope(true) @@ -589,7 +658,7 @@ func handleUpdateContactCustomAttributes(r *fastglue.Request) error { // enforceConversationAccess fetches the conversation and checks if the user has access to it. func enforceConversationAccess(app *App, uuid string, user umodels.User) (*cmodels.Conversation, error) { - conversation, err := app.conversation.GetConversation(0, uuid) + conversation, err := app.conversation.GetConversation(0, uuid, "") if err != nil { return nil, err } @@ -739,16 +808,16 @@ func handleCreateConversation(r *fastglue.Request) error { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`initiator`"), nil, envelope.InputError) } - // Assign the conversation to the agent or team. - if req.AssignedAgentID > 0 { - app.conversation.UpdateConversationUserAssignee(conversationUUID, req.AssignedAgentID, user) - } + // Assign the conversation to team/agent if provided, always assign team first as it clears assigned agent. if req.AssignedTeamID > 0 { app.conversation.UpdateConversationTeamAssignee(conversationUUID, req.AssignedTeamID, user) } + if req.AssignedAgentID > 0 { + app.conversation.UpdateConversationUserAssignee(conversationUUID, req.AssignedAgentID, user) + } // Trigger webhook event for conversation created. - conversation, err := app.conversation.GetConversation(conversationID, "") + conversation, err := app.conversation.GetConversation(conversationID, "", "") if err == nil { app.webhook.TriggerEvent(wmodels.EventConversationCreated, conversation) } diff --git a/cmd/csat.go b/cmd/csat.go index abfd08b9..7042cfbc 100644 --- a/cmd/csat.go +++ b/cmd/csat.go @@ -41,7 +41,7 @@ func handleShowCSAT(r *fastglue.Request) error { }) } - conversation, err := app.conversation.GetConversation(csat.ConversationID, "") + conversation, err := app.conversation.GetConversation(csat.ConversationID, "", "") if err != nil { return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{ "Data": map[string]interface{}{ diff --git a/cmd/draft.go b/cmd/draft.go new file mode 100644 index 00000000..f80af18d --- /dev/null +++ b/cmd/draft.go @@ -0,0 +1,100 @@ +package main + +import ( + "encoding/json" + "strings" + + amodels "github.com/abhinavxd/libredesk/internal/auth/models" + "github.com/abhinavxd/libredesk/internal/envelope" + "github.com/valyala/fasthttp" + "github.com/zerodha/fastglue" +) + +const maxMetaSize = 32 * 1024 // 32KB + +type draftReq struct { + Content string `json:"content"` + Meta json.RawMessage `json:"meta"` +} + +// handleUpsertConversationDraft saves or updates a draft for a conversation. +func handleUpsertConversationDraft(r *fastglue.Request) error { + var ( + app = r.Context.(*App) + auser = r.RequestCtx.UserValue("user").(amodels.User) + uuid = r.RequestCtx.UserValue("uuid").(string) + req = draftReq{} + ) + + user, err := app.user.GetAgent(auser.ID, "") + if err != nil { + return sendErrorEnvelope(r, err) + } + + // Check access to conversation. + conv, err := enforceConversationAccess(app, uuid, user) + if err != nil { + return sendErrorEnvelope(r, err) + } + + if err := r.Decode(&req, "json"); err != nil { + app.lo.Error("error decoding draft request", "error", err) + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError) + } + + if len(req.Meta) > maxMetaSize { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "meta"), nil, envelope.InputError) + } + + // Validate content is not empty + if strings.TrimSpace(req.Content) == "" && (len(req.Meta) == 0 || string(req.Meta) == "{}") { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "content"), nil, envelope.InputError) + } + + draft, err := app.conversation.UpsertConversationDraft(conv.ID, user.ID, req.Content, req.Meta) + if err != nil { + return sendErrorEnvelope(r, err) + } + + return r.SendEnvelope(draft) +} + +// handleGetAllDrafts retrieves all drafts for the current user. +func handleGetAllDrafts(r *fastglue.Request) error { + var ( + app = r.Context.(*App) + auser = r.RequestCtx.UserValue("user").(amodels.User) + ) + + user, err := app.user.GetAgent(auser.ID, "") + if err != nil { + return sendErrorEnvelope(r, err) + } + + drafts, err := app.conversation.GetAllUserDrafts(user.ID) + if err != nil { + return sendErrorEnvelope(r, err) + } + + return r.SendEnvelope(drafts) +} + +// handleDeleteConversationDraft deletes a draft for a conversation. +func handleDeleteConversationDraft(r *fastglue.Request) error { + var ( + app = r.Context.(*App) + auser = r.RequestCtx.UserValue("user").(amodels.User) + uuid = r.RequestCtx.UserValue("uuid").(string) + ) + + user, err := app.user.GetAgent(auser.ID, "") + if err != nil { + return sendErrorEnvelope(r, err) + } + + if err := app.conversation.DeleteConversationDraft(0, uuid, user.ID); err != nil { + return sendErrorEnvelope(r, err) + } + + return r.SendEnvelope(true) +} diff --git a/cmd/handlers.go b/cmd/handlers.go index 3a71a07f..1b0a012e 100644 --- a/cmd/handlers.go +++ b/cmd/handlers.go @@ -6,6 +6,7 @@ import ( "net/http" "path" "path/filepath" + "strconv" "strings" "github.com/abhinavxd/libredesk/internal/envelope" @@ -30,8 +31,8 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) { // Public config for app initialization. g.GET("/api/v1/config", handleGetConfig) - // Media. - g.GET("/uploads/{uuid}", auth(handleServeMedia)) + // Media - supports both authenticated access and signed URLs. + g.GET("/uploads/{uuid}", authOrSignedURL(handleServeMedia)) g.POST("/api/v1/media", auth(handleMediaUpload)) // Settings. @@ -51,6 +52,7 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) { g.GET("/api/v1/conversations/all", perm(handleGetAllConversations, "conversations:read_all")) g.GET("/api/v1/conversations/unassigned", perm(handleGetUnassignedConversations, "conversations:read_unassigned")) g.GET("/api/v1/conversations/assigned", perm(handleGetAssignedConversations, "conversations:read_assigned")) + g.GET("/api/v1/conversations/mentioned", perm(handleGetMentionedConversations, "conversations:read")) g.GET("/api/v1/teams/{id}/conversations/unassigned", perm(handleGetTeamUnassignedConversations, "conversations:read_team_inbox")) g.GET("/api/v1/views/{id}/conversations", perm(handleGetViewConversations, "conversations:read")) g.GET("/api/v1/conversations/{uuid}", perm(handleGetConversation, "conversations:read")) @@ -62,6 +64,7 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) { g.PUT("/api/v1/conversations/{uuid}/priority", perm(handleUpdateConversationPriority, "conversations:update_priority")) g.PUT("/api/v1/conversations/{uuid}/status", perm(handleUpdateConversationStatus, "conversations:update_status")) g.PUT("/api/v1/conversations/{uuid}/last-seen", perm(handleUpdateConversationAssigneeLastSeen, "conversations:read")) + g.PUT("/api/v1/conversations/{uuid}/mark-unread", perm(handleMarkConversationAsUnread, "conversations:read")) g.POST("/api/v1/conversations/{uuid}/tags", perm(handleUpdateConversationtags, "conversations:update_tags")) g.GET("/api/v1/conversations/{cuuid}/messages/{uuid}", perm(handleGetMessage, "messages:read")) g.GET("/api/v1/conversations/{uuid}/messages", perm(handleGetMessages, "messages:read")) @@ -70,6 +73,10 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) { g.POST("/api/v1/conversations", perm(handleCreateConversation, "conversations:write")) g.PUT("/api/v1/conversations/{uuid}/custom-attributes", auth(handleUpdateConversationCustomAttributes)) g.PUT("/api/v1/conversations/{uuid}/contacts/custom-attributes", auth(handleUpdateContactCustomAttributes)) + // Draft endpoints + g.GET("/api/v1/drafts", auth(handleGetAllDrafts)) + g.POST("/api/v1/conversations/{uuid}/draft", auth(handleUpsertConversationDraft)) + g.DELETE("/api/v1/conversations/{uuid}/draft", auth(handleDeleteConversationDraft)) // Search. g.GET("/api/v1/conversations/search", perm(handleSearchConversations, "conversations:read")) @@ -82,6 +89,14 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) { g.PUT("/api/v1/views/me/{id}", perm(handleUpdateUserView, "view:manage")) g.DELETE("/api/v1/views/me/{id}", perm(handleDeleteUserView, "view:manage")) + g.GET("/api/v1/views/shared", auth(handleGetSharedViews)) + + g.GET("/api/v1/shared-views", perm(handleGetAllSharedViews, "shared_views:manage")) + g.GET("/api/v1/shared-views/{id}", perm(handleGetSharedView, "shared_views:manage")) + g.POST("/api/v1/shared-views", perm(handleCreateSharedView, "shared_views:manage")) + g.PUT("/api/v1/shared-views/{id}", perm(handleUpdateSharedView, "shared_views:manage")) + g.DELETE("/api/v1/shared-views/{id}", perm(handleDeleteSharedView, "shared_views:manage")) + // Status and priority. g.GET("/api/v1/statuses", auth(handleGetStatuses)) g.POST("/api/v1/statuses", perm(handleCreateStatus, "status:manage")) @@ -116,6 +131,8 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) { g.POST("/api/v1/agents", perm(handleCreateAgent, "users:manage")) g.PUT("/api/v1/agents/{id}", perm(handleUpdateAgent, "users:manage")) g.DELETE("/api/v1/agents/{id}", perm(handleDeleteAgent, "users:manage")) + g.POST("/api/v1/agents/import", perm(handleImportAgents, "users:manage")) + g.GET("/api/v1/agents/import/status", perm(handleGetAgentImportStatus, "users:manage")) g.POST("/api/v1/agents/{id}/api-key", perm(handleGenerateAPIKey, "users:manage")) g.DELETE("/api/v1/agents/{id}/api-key", perm(handleRevokeAPIKey, "users:manage")) g.POST("/api/v1/agents/reset-password", tryAuth(handleResetPassword)) @@ -158,6 +175,10 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) { g.PUT("/api/v1/inboxes/{id}", perm(handleUpdateInbox, "inboxes:manage")) g.DELETE("/api/v1/inboxes/{id}", perm(handleDeleteInbox, "inboxes:manage")) + // OAuth endpoints for email inboxes. + g.POST("/api/v1/inboxes/oauth/{provider}/authorize", perm(handleOAuthAuthorize, "inboxes:manage")) + g.GET("/api/v1/inboxes/oauth/{provider}/callback", perm(handleOAuthCallback, "inboxes:manage")) + // Roles. g.GET("/api/v1/roles", auth(handleGetRoles)) g.GET("/api/v1/roles/{id}", perm(handleGetRole, "roles:manage")) @@ -178,6 +199,9 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) { g.GET("/api/v1/reports/overview/sla", perm(handleOverviewSLA, "reports:manage")) g.GET("/api/v1/reports/overview/counts", perm(handleOverviewCounts, "reports:manage")) g.GET("/api/v1/reports/overview/charts", perm(handleOverviewCharts, "reports:manage")) + g.GET("/api/v1/reports/overview/csat", perm(handleOverviewCSAT, "reports:manage")) + g.GET("/api/v1/reports/overview/messages", perm(handleOverviewMessageVolume, "reports:manage")) + g.GET("/api/v1/reports/overview/tags", perm(handleOverviewTagDistribution, "reports:manage")) // Templates. g.GET("/api/v1/templates", perm(handleGetTemplates, "templates:manage")) @@ -194,7 +218,7 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) { g.DELETE("/api/v1/business-hours/{id}", perm(handleDeleteBusinessHour, "business_hours:manage")) // SLAs. - g.GET("/api/v1/sla", perm(handleGetSLAs, "sla:manage")) + g.GET("/api/v1/sla", auth(handleGetSLAs)) g.GET("/api/v1/sla/{id}", perm(handleGetSLA, "sla:manage")) g.POST("/api/v1/sla", perm(handleCreateSLA, "sla:manage")) g.PUT("/api/v1/sla/{id}", perm(handleUpdateSLA, "sla:manage")) @@ -218,6 +242,14 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) { // CSAT. g.POST("/api/v1/csat/{uuid}/response", handleSubmitCSATResponse) + // User notifications. + g.GET("/api/v1/notifications", auth(handleGetUserNotifications)) + g.GET("/api/v1/notifications/stats", auth(handleGetUserNotificationStats)) + g.PUT("/api/v1/notifications/{id}/read", auth(handleMarkNotificationAsRead)) + g.PUT("/api/v1/notifications/read-all", auth(handleMarkAllNotificationsAsRead)) + g.DELETE("/api/v1/notifications/{id}", auth(handleDeleteNotification)) + g.DELETE("/api/v1/notifications", auth(handleDeleteAllNotifications)) + // WebSocket. g.GET("/ws", auth(func(r *fastglue.Request) error { return handleWS(r, hub) @@ -450,6 +482,20 @@ func serveWidgetJS(r *fastglue.Request) error { return nil } +// getPagination extracts page and page_size from query params with defaults. +// Defaults: page=1, pageSize=30 +func getPagination(r *fastglue.Request) (page, pageSize int) { + page, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page"))) + pageSize, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page_size"))) + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 30 + } + return page, pageSize +} + // sendErrorEnvelope sends a standardized error response to the client. func sendErrorEnvelope(r *fastglue.Request, err error) error { e, ok := err.(envelope.Error) diff --git a/cmd/inboxes.go b/cmd/inboxes.go index a42990b4..394ade39 100644 --- a/cmd/inboxes.go +++ b/cmd/inboxes.go @@ -6,6 +6,7 @@ import ( "strconv" "github.com/abhinavxd/libredesk/internal/envelope" + "github.com/abhinavxd/libredesk/internal/inbox/channel/email/oauth" "github.com/abhinavxd/libredesk/internal/inbox/channel/livechat" imodels "github.com/abhinavxd/libredesk/internal/inbox/models" "github.com/valyala/fasthttp" @@ -71,7 +72,7 @@ func handleCreateInbox(r *fastglue.Request) error { // Clear passwords before returning. if err := createdInbox.ClearPasswords(); err != nil { app.lo.Error("error clearing inbox passwords from response", "error", err) - return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.inbox}"), nil) + return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.inbox}"), nil) } return r.SendEnvelope(createdInbox) @@ -109,7 +110,7 @@ func handleUpdateInbox(r *fastglue.Request) error { // Clear passwords before returning. if err := updatedInbox.ClearPasswords(); err != nil { app.lo.Error("error clearing inbox passwords from response", "error", err) - return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.inbox}"), nil) + return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.inbox}"), nil) } return r.SendEnvelope(updatedInbox) @@ -168,13 +169,13 @@ func validateInbox(app *App, inbox imodels.Inbox) error { return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalidFromAddress"), nil) } } - if len(inbox.Config) == 0 { + if len(inb.Config) == 0 { return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "config"), nil) } - if inbox.Name == "" { + if inb.Name == "" { return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "name"), nil) } - if inbox.Channel == "" { + if inb.Channel == "" { return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "channel"), nil) } @@ -201,9 +202,79 @@ func validateInbox(app *App, inbox imodels.Inbox) error { // Ensure linked inbox is enabled if !linkedInbox.Enabled { return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "linked_email_inbox_id"), nil) + } } } + // Validate email channel config. + if inb.Channel == inbox.ChannelEmail { + if err := validateEmailConfig(app, inb.Config); err != nil { + return err + } + } + return nil +} + +// validateEmailConfig validates the email inbox configuration. +func validateEmailConfig(app *App, configJSON json.RawMessage) error { + var cfg imodels.Config + if err := json.Unmarshal(configJSON, &cfg); err != nil { + return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "config"), nil) + } + + // Validate auth_type. + if cfg.AuthType != "" && cfg.AuthType != imodels.AuthTypePassword && cfg.AuthType != imodels.AuthTypeOAuth2 { + return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "auth_type"), nil) + } + + // Validate OAuth config if auth_type is oauth2. + if cfg.AuthType == imodels.AuthTypeOAuth2 { + if cfg.OAuth == nil { + return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "oauth"), nil) + } + if cfg.OAuth.Provider != string(oauth.ProviderGoogle) && cfg.OAuth.Provider != string(oauth.ProviderMicrosoft) { + return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "oauth.provider"), nil) + } + if cfg.OAuth.ClientID == "" { + return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "oauth.client_id"), nil) + } + } + + // Validate SMTP configs. + for i, smtp := range cfg.SMTP { + if smtp.Host == "" { + return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "smtp.host"), nil) + } + if smtp.Port <= 0 { + return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "smtp.port"), nil) + } + // Validate auth_protocol for password auth. + if cfg.AuthType != imodels.AuthTypeOAuth2 { + validAuthProtocols := map[string]bool{"": true, "none": true, "plain": true, "login": true, "cram": true} + if !validAuthProtocols[cfg.SMTP[i].AuthProtocol] { + return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "smtp.auth_protocol"), nil) + } + } + } + + // Validate IMAP configs. + for _, imap := range cfg.IMAP { + if imap.Host == "" { + return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "imap.host"), nil) + } + if imap.Port <= 0 { + return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "imap.port"), nil) + } + if imap.Mailbox == "" { + return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "imap.mailbox"), nil) + } + // Validate tls_type. + validTLSTypes := map[string]bool{"none": true, "starttls": true, "tls": true} + if !validTLSTypes[imap.TLSType] { + return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "imap.tls_type"), nil) + } + } + return nil } diff --git a/cmd/init.go b/cmd/init.go index 898e0109..802aa2e7 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -8,6 +8,7 @@ import ( "log" "os" "path/filepath" + "strings" "time" "html/template" @@ -25,6 +26,7 @@ import ( "github.com/abhinavxd/libredesk/internal/conversation/status" "github.com/abhinavxd/libredesk/internal/csat" customAttribute "github.com/abhinavxd/libredesk/internal/custom_attribute" + "github.com/abhinavxd/libredesk/internal/importer" "github.com/abhinavxd/libredesk/internal/inbox" "github.com/abhinavxd/libredesk/internal/inbox/channel/email" "github.com/abhinavxd/libredesk/internal/inbox/channel/livechat" @@ -54,6 +56,7 @@ import ( kjson "github.com/knadh/koanf/parsers/json" "github.com/knadh/koanf/parsers/toml" "github.com/knadh/koanf/providers/confmap" + "github.com/knadh/koanf/providers/env/v2" "github.com/knadh/koanf/providers/file" "github.com/knadh/koanf/providers/posflag" "github.com/knadh/koanf/providers/rawbytes" @@ -76,17 +79,41 @@ type constants struct { MaxFileUploadSizeMB int } -// Config loads config files into koanf. +// Config loads config from files and environment variables into koanf. func initConfig(ko *koanf.Koanf) { for _, f := range ko.Strings("config") { log.Println("reading config file:", f) if err := ko.Load(file.Provider(f), toml.Parser()); err != nil { if os.IsNotExist(err) { - log.Fatal("error config file not found.") + log.Printf("WARNING: Config file not found. Continuing with defaults and environment variables.") + continue } log.Fatalf("error loading config from file: %v.", err) } } + // Load environment variables with `LIBREDESK_` prefix. + ko.Load(env.Provider(".", env.Opt{ + Prefix: "LIBREDESK_", + TransformFunc: func(key, val string) (string, any) { + // Transform the key. + key = strings.ReplaceAll(strings.ToLower(strings.TrimPrefix(key, "LIBREDESK_")), "__", ".") + return key, val + }, + }), nil) +} + +// validateConfig logs warnings/fatals for invalid config values. +func validateConfig(ko *koanf.Koanf) { + encKey := ko.MustString("app.encryption_key") + + if len(encKey) != 32 { + log.Fatalf("encryption_key must be exactly 32 characters, got %d", len(encKey)) + } + + // Warn if using sample config value. + if encKey == sampleEncKey { + colorlog.Red("WARNING: You are using the sample encryption_key from config.sample.toml. Change it immediately. Generate a secure key with `openssl rand -hex 16`") + } } // initFlags initializes the commandline flags. @@ -186,8 +213,9 @@ func loadSettings(m *setting.Manager) { // initSettings inits setting manager. func initSettings(db *sqlx.DB) *setting.Manager { s, err := setting.New(setting.Opts{ - DB: db, - Lo: initLogger("settings"), + DB: db, + Lo: initLogger("settings"), + EncryptionKey: ko.MustString("app.encryption_key"), }) if err != nil { log.Fatalf("error initializing setting manager: %v", err) @@ -214,7 +242,6 @@ func initConversations( status *status.Manager, priority *priority.Manager, hub *ws.Hub, - notif *notifier.Service, db *sqlx.DB, inboxStore *inbox.Manager, userStore *user.Manager, @@ -225,6 +252,7 @@ func initConversations( automationEngine *automation.Engine, template *tmpl.Manager, webhook *webhook.Manager, + dispatcher *notifier.Dispatcher, ) *conversation.Manager { continuityConfig := &conversation.ContinuityConfig{} @@ -243,8 +271,7 @@ func initConversations( if ko.Exists("conversation.continuity.max_messages_per_email") { continuityConfig.MaxMessagesPerEmail = ko.MustInt("conversation.continuity.max_messages_per_email") } - - c, err := conversation.New(hub, i18n, notif, sla, status, priority, inboxStore, userStore, teamStore, mediaStore, settings, csat, automationEngine, template, webhook, conversation.Opts{ + c, err := conversation.New(hub, i18n, sla, status, priority, inboxStore, userStore, teamStore, mediaStore, settings, csat, automationEngine, template, webhook, dispatcher, conversation.Opts{ DB: db, Lo: initLogger("conversation_manager"), OutgoingMessageQueueSize: ko.MustInt("message.outgoing_queue_size"), @@ -314,13 +341,13 @@ func initBusinessHours(db *sqlx.DB, i18n *i18n.I18n) *businesshours.Manager { } // initSLA inits SLA manager. -func initSLA(db *sqlx.DB, teamManager *team.Manager, settings *setting.Manager, businessHours *businesshours.Manager, notifier *notifier.Service, template *tmpl.Manager, userManager *user.Manager, i18n *i18n.I18n) *sla.Manager { +func initSLA(db *sqlx.DB, teamManager *team.Manager, settings *setting.Manager, businessHours *businesshours.Manager, template *tmpl.Manager, userManager *user.Manager, i18n *i18n.I18n, dispatcher *notifier.Dispatcher) *sla.Manager { var lo = initLogger("sla") m, err := sla.New(sla.Opts{ DB: db, Lo: lo, I18n: i18n, - }, teamManager, settings, businessHours, notifier, template, userManager) + }, teamManager, settings, businessHours, template, userManager, dispatcher) if err != nil { log.Fatalf("error initializing SLA manager: %v", err) } @@ -451,12 +478,11 @@ func initTeam(db *sqlx.DB, i18n *i18n.I18n) *team.Manager { } // initMedia inits media manager. -func initMedia(db *sqlx.DB, i18n *i18n.I18n) *media.Manager { +func initMedia(db *sqlx.DB, i18n *i18n.I18n, settings *setting.Manager) *media.Manager { var ( - store media.Store - err error - appRootURL = ko.String("app.root_url") - lo = initLogger("media") + store media.Store + err error + lo = initLogger("media") ) switch s := ko.MustString("upload.provider"); s { case "s3": @@ -476,12 +502,24 @@ func initMedia(db *sqlx.DB, i18n *i18n.I18n) *media.Manager { log.Fatalf("error initializing s3 media store: %v", err) } case "fs": + // Default expiry to 1h if not set. + fsExpiry := ko.Duration("upload.fs.expiry") + if fsExpiry == 0 { + fsExpiry = 1 * time.Hour + } store, err = fs.New(fs.Opts{ UploadURI: "/uploads", UploadPath: filepath.Clean(ko.String("upload.fs.upload_path")), - RootURL: appRootURL, - Expiry: ko.Duration("upload.fs.expiry"), - Secret: ko.String("upload.fs.secret"), + RootURL: func() string { + rootURL, err := settings.GetAppRootURL() + if err != nil { + // Fallback to config if settings fetch fails + return ko.String("app.root_url") + } + return rootURL + }, + SigningKey: ko.MustString("app.encryption_key"), + Expiry: fsExpiry, }) if err != nil { log.Fatalf("error initializing fs media store: %v", err) @@ -505,7 +543,7 @@ func initMedia(db *sqlx.DB, i18n *i18n.I18n) *media.Manager { // initInbox initializes the inbox manager without registering inboxes. func initInbox(db *sqlx.DB, i18n *i18n.I18n) *inbox.Manager { var lo = initLogger("inbox-manager") - mgr, err := inbox.New(lo, db, i18n) + mgr, err := inbox.New(lo, db, i18n, ko.MustString("app.encryption_key")) if err != nil { log.Fatalf("error initializing inbox manager: %v", err) } @@ -541,12 +579,12 @@ func initAutoAssigner(teamManager *team.Manager, userManager *user.Manager, conv // initNotifier initializes the notifier service with available providers. func initNotifier() *notifier.Service { - smtpCfg := email.SMTPConfig{} + smtpCfg := imodels.SMTPConfig{} if err := ko.UnmarshalWithConf("notification.email", &smtpCfg, koanf.UnmarshalConf{Tag: "json"}); err != nil { log.Fatalf("error unmarshalling email notification provider config: %v", err) } - emailNotifier, err := emailnotifier.New([]email.SMTPConfig{smtpCfg}, emailnotifier.Opts{ + emailNotifier, err := emailnotifier.New([]imodels.SMTPConfig{smtpCfg}, emailnotifier.Opts{ Lo: initLogger("email-notifier"), FromEmail: ko.String("notification.email.email_address"), }) @@ -561,9 +599,9 @@ func initNotifier() *notifier.Service { return notifier.NewService(notifierProviders, ko.MustInt("notification.concurrency"), ko.MustInt("notification.queue_size"), initLogger("notifier")) } -// initEmailInbox initializes the email inbox. -func initEmailInbox(inboxRecord imodels.Inbox, msgStore inbox.MessageStore, usrStore inbox.UserStore) (inbox.Inbox, error) { - var config email.Config +// initEmailInbox loads inbox config from DB and initializes the email inbox. +func initEmailInbox(inboxRecord imodels.Inbox, msgStore inbox.MessageStore, usrStore inbox.UserStore, mgr *inbox.Manager) (inbox.Inbox, error) { + var config imodels.Config // Load JSON data into Koanf. if err := ko.Load(rawbytes.Provider([]byte(inboxRecord.Config)), kjson.Parser()); err != nil { @@ -588,10 +626,30 @@ func initEmailInbox(inboxRecord imodels.Inbox, msgStore inbox.MessageStore, usrS log.Printf("WARNING: No `from` email address set for `%s` inbox: Name: `%s`", inboxRecord.Channel, inboxRecord.Name) } + // Callback to persist refreshed tokens in DB. + tokenRefreshCallback := func(inboxID int, updatedConfig imodels.Config) error { + // Marshal updated config to JSON + updatedConfigJSON, err := json.Marshal(updatedConfig) + if err != nil { + log.Printf("ERROR: Failed to marshal updated config during token refresh for inbox %d: %v", inboxID, err) + return err + } + + // Persist updated config to DB + if err := mgr.UpdateConfig(inboxID, updatedConfigJSON); err != nil { + log.Printf("ERROR: Failed to persist refreshed tokens during operation for inbox %d: %v", inboxID, err) + return err + } + + log.Printf("INFO: Successfully persisted refreshed tokens during operation for inbox: %d", inboxID) + return nil + } + inbox, err := email.New(msgStore, usrStore, email.Opts{ - ID: inboxRecord.ID, - Config: config, - Lo: initLogger("email_inbox"), + ID: inboxRecord.ID, + Config: config, + Lo: initLogger("email_inbox"), + TokenRefreshCallback: tokenRefreshCallback, }) if err != nil { @@ -643,10 +701,22 @@ func initializeInboxes(inboxR imodels.Inbox, msgStore inbox.MessageStore, usrSto } } +// makeInboxInitializer creates an inbox initializer function. +func makeInboxInitializer(mgr *inbox.Manager) func(imodels.Inbox, inbox.MessageStore, inbox.UserStore) (inbox.Inbox, error) { + return func(inboxR imodels.Inbox, msgStore inbox.MessageStore, usrStore inbox.UserStore) (inbox.Inbox, error) { + switch inboxR.Channel { + case inbox.ChannelEmail: + return initEmailInbox(inboxR, msgStore, usrStore, mgr) + default: + return nil, fmt.Errorf("unknown inbox channel: %s", inboxR.Channel) + } + } +} + // reloadInboxes reloads all inboxes. func reloadInboxes(app *App) error { app.lo.Info("reloading inboxes") - return app.inbox.Reload(ctx, initializeInboxes) + return app.inbox.Reload(ctx, makeInboxInitializer(app.inbox)) } // startInboxes registers the active inboxes and starts receiver for each. @@ -654,7 +724,7 @@ func startInboxes(ctx context.Context, mgr *inbox.Manager, msgStore inbox.Messag mgr.SetMessageStore(msgStore) mgr.SetUserStore(usrStore) - if err := mgr.InitInboxes(initializeInboxes); err != nil { + if err := mgr.InitInboxes(makeInboxInitializer(mgr)); err != nil { log.Fatalf("error initializing inboxes: %v", err) } @@ -732,9 +802,10 @@ func buildProviders(o *oidc.Manager) ([]auth_.Provider, error) { func initOIDC(db *sqlx.DB, settings *setting.Manager, i18n *i18n.I18n) *oidc.Manager { lo := initLogger("oidc") o, err := oidc.New(oidc.Opts{ - DB: db, - Lo: lo, - I18n: i18n, + DB: db, + Lo: lo, + I18n: i18n, + EncryptionKey: ko.MustString("app.encryption_key"), }, settings) if err != nil { log.Fatalf("error initializing oidc: %v", err) @@ -759,8 +830,19 @@ func initI18n(fs stuffbin.FileSystem) *i18n.I18n { // initRedis inits redis DB. func initRedis() *redis.Client { + // Load options from redis URL if set. + redisURL := ko.String("redis.url") + if redisURL != "" { + options, err := redis.ParseURL(redisURL) + if err != nil { + log.Fatalf("error parsing redis url: %v", err) + } + return redis.NewClient(options) + } + // Load from individual config options. return redis.NewClient(&redis.Options{ Addr: ko.MustString("redis.address"), + Username: ko.String("redis.user"), Password: ko.String("redis.password"), DB: ko.Int("redis.db"), }) @@ -835,9 +917,10 @@ func initPriority(db *sqlx.DB, i18n *i18n.I18n) *priority.Manager { func initAI(db *sqlx.DB, i18n *i18n.I18n) *ai.Manager { lo := initLogger("ai") m, err := ai.New(ai.Opts{ - DB: db, - Lo: lo, - I18n: i18n, + DB: db, + Lo: lo, + I18n: i18n, + EncryptionKey: ko.MustString("app.encryption_key"), }) if err != nil { log.Fatalf("error initializing AI manager: %v", err) @@ -905,12 +988,13 @@ func initReport(db *sqlx.DB, i18n *i18n.I18n) *report.Manager { func initWebhook(db *sqlx.DB, i18n *i18n.I18n) *webhook.Manager { var lo = initLogger("webhook") m, err := webhook.New(webhook.Opts{ - DB: db, - Lo: lo, - I18n: i18n, - Workers: ko.MustInt("webhook.workers"), - QueueSize: ko.MustInt("webhook.queue_size"), - Timeout: ko.MustDuration("webhook.timeout"), + DB: db, + Lo: lo, + I18n: i18n, + Workers: ko.MustInt("webhook.workers"), + QueueSize: ko.MustInt("webhook.queue_size"), + Timeout: ko.MustDuration("webhook.timeout"), + EncryptionKey: ko.MustString("app.encryption_key"), }) if err != nil { log.Fatalf("error initializing webhook manager: %v", err) @@ -918,6 +1002,38 @@ func initWebhook(db *sqlx.DB, i18n *i18n.I18n) *webhook.Manager { return m } +// initUserNotification inits user notification manager. +func initUserNotification(db *sqlx.DB, i18n *i18n.I18n) *notifier.UserNotificationManager { + var lo = initLogger("user-notification") + m, err := notifier.NewUserNotificationManager(notifier.UserNotificationOpts{ + DB: db, + Lo: lo, + I18n: i18n, + }) + if err != nil { + log.Fatalf("error initializing user notification manager: %v", err) + } + return m +} + +// initImporter inits the importer manager. +func initImporter(i18n *i18n.I18n) *importer.Importer { + return importer.New(importer.Opts{ + Lo: initLogger("importer"), + I18n: i18n, + }) +} + +// initNotifDispatcher initializes the notification dispatcher. +func initNotifDispatcher(userNotification *notifier.UserNotificationManager, outbound *notifier.Service, wsHub *ws.Hub) *notifier.Dispatcher { + return notifier.NewDispatcher(notifier.DispatcherOpts{ + InApp: userNotification, + Outbound: outbound, + WSHub: wsHub, + Lo: initLogger("notification-dispatcher"), + }) +} + // initLogger initializes a logf logger. func initLogger(src string) *logf.Logger { lvl, env := ko.MustString("app.log_level"), ko.MustString("app.env") diff --git a/cmd/macro.go b/cmd/macro.go index 6d1b15ab..1ede93c9 100644 --- a/cmd/macro.go +++ b/cmd/macro.go @@ -146,7 +146,7 @@ func handleApplyMacro(r *fastglue.Request) error { } // Enforce conversation access. - conversation, err := app.conversation.GetConversation(0, conversationUUID) + conversation, err := app.conversation.GetConversation(0, conversationUUID, "") if err != nil { return sendErrorEnvelope(r, err) } diff --git a/cmd/main.go b/cmd/main.go index ae3c935e..0c226c2a 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -1,6 +1,7 @@ package main import ( + "cmp" "context" "fmt" "log" @@ -27,11 +28,13 @@ import ( "github.com/abhinavxd/libredesk/internal/search" "github.com/abhinavxd/libredesk/internal/sla" "github.com/abhinavxd/libredesk/internal/view" + "github.com/redis/go-redis/v9" "github.com/abhinavxd/libredesk/internal/automation" "github.com/abhinavxd/libredesk/internal/conversation" "github.com/abhinavxd/libredesk/internal/conversation/priority" "github.com/abhinavxd/libredesk/internal/conversation/status" + "github.com/abhinavxd/libredesk/internal/importer" "github.com/abhinavxd/libredesk/internal/inbox" "github.com/abhinavxd/libredesk/internal/media" "github.com/abhinavxd/libredesk/internal/oidc" @@ -63,40 +66,47 @@ var ( versionString string ) +const ( + sampleEncKey = "your-32-char-random-string-here!" +) + // App is the global app context which is passed and injected in the http handlers. type App struct { - fs stuffbin.FileSystem - consts atomic.Value - auth *auth_.Auth - authz *authz.Enforcer - i18n *i18n.I18n - lo *logf.Logger - oidc *oidc.Manager - media *media.Manager - setting *setting.Manager - role *role.Manager - user *user.Manager - team *team.Manager - status *status.Manager - priority *priority.Manager - tag *tag.Manager - inbox *inbox.Manager - tmpl *template.Manager - macro *macro.Manager - conversation *conversation.Manager - automation *automation.Engine - businessHours *businesshours.Manager - sla *sla.Manager - csat *csat.Manager - view *view.Manager - ai *ai.Manager - search *search.Manager - activityLog *activitylog.Manager - notifier *notifier.Service - customAttribute *customAttribute.Manager - report *report.Manager - webhook *webhook.Manager - rateLimit *ratelimit.Limiter + fs stuffbin.FileSystem + consts atomic.Value + auth *auth_.Auth + authz *authz.Enforcer + i18n *i18n.I18n + lo *logf.Logger + oidc *oidc.Manager + media *media.Manager + setting *setting.Manager + role *role.Manager + user *user.Manager + team *team.Manager + status *status.Manager + priority *priority.Manager + tag *tag.Manager + inbox *inbox.Manager + tmpl *template.Manager + macro *macro.Manager + conversation *conversation.Manager + automation *automation.Engine + businessHours *businesshours.Manager + sla *sla.Manager + csat *csat.Manager + view *view.Manager + ai *ai.Manager + search *search.Manager + activityLog *activitylog.Manager + notifier *notifier.Service + userNotification *notifier.UserNotificationManager + customAttribute *customAttribute.Manager + report *report.Manager + webhook *webhook.Manager + rateLimit *ratelimit.Limiter + redis *redis.Client + importer *importer.Importer // Global state that stores data on an available app update. update *AppUpdate @@ -166,6 +176,9 @@ func main() { settings := initSettings(db) loadSettings(settings) + // Validate config. + validateConfig(ko) + // Fallback for config typo. Logs a warning but continues to work with the incorrect key. // Uses 'message.message_outgoing_scan_interval' (correct key) as default key, falls back to the common typo. msgOutgoingScanIntervalKey := "message.message_outgoing_scan_interval" @@ -179,6 +192,7 @@ func main() { var ( autoAssignInterval = ko.MustDuration("autoassigner.autoassign_interval") unsnoozeInterval = ko.MustDuration("conversation.unsnooze_interval") + draftRetentionDuration = cmp.Or(ko.Duration("conversation.draft_retention_duration"), 360*time.Hour) automationWorkers = ko.MustInt("automation.worker_count") messageOutgoingQWorkers = ko.MustDuration("message.outgoing_queue_workers") messageIncomingQWorkers = ko.MustDuration("message.incoming_queue_workers") @@ -194,7 +208,7 @@ func main() { priority = initPriority(db, i18n) auth = initAuth(oidc, rdb, i18n) template = initTemplate(db, fs, constants, i18n) - media = initMedia(db, i18n) + media = initMedia(db, i18n, settings) inbox = initInbox(db, i18n) team = initTeam(db, i18n) businessHours = initBusinessHours(db, i18n) @@ -202,9 +216,11 @@ func main() { user = initUser(i18n, db) wsHub = initWS(user) notifier = initNotifier() + userNotification = initUserNotification(db, i18n) + notifDispatcher = initNotifDispatcher(userNotification, notifier, wsHub) automation = initAutomationEngine(db, i18n) - sla = initSLA(db, team, settings, businessHours, notifier, template, user, i18n) - conversation = initConversations(i18n, sla, status, priority, wsHub, notifier, db, inbox, user, team, media, settings, csat, automation, template, webhook) + sla = initSLA(db, team, settings, businessHours, template, user, i18n, notifDispatcher) + conversation = initConversations(i18n, sla, status, priority, wsHub, db, inbox, user, team, media, settings, csat, automation, template, webhook, notifDispatcher) autoassigner = initAutoAssigner(team, user, conversation) rateLimiter = initRateLimit(rdb) ) @@ -226,40 +242,45 @@ func main() { go sla.SendNotifications(ctx) go media.DeleteUnlinkedMedia(ctx) go user.MonitorAgentAvailability(ctx) + go conversation.RunDraftCleaner(ctx, draftRetentionDuration) + go userNotification.RunNotificationCleaner(ctx) var app = &App{ - lo: lo, - fs: fs, - sla: sla, - oidc: oidc, - i18n: i18n, - auth: auth, - media: media, - setting: settings, - inbox: inbox, - user: user, - team: team, - status: status, - priority: priority, - tmpl: template, - notifier: notifier, - consts: atomic.Value{}, - conversation: conversation, - automation: automation, - businessHours: businessHours, - activityLog: initActivityLog(db, i18n), - customAttribute: initCustomAttribute(db, i18n), - authz: initAuthz(i18n), - view: initView(db, i18n), - report: initReport(db, i18n), - csat: initCSAT(db, i18n), - search: initSearch(db, i18n), - role: initRole(db, i18n), - tag: initTag(db, i18n), - macro: initMacro(db, i18n), - ai: initAI(db, i18n), - webhook: webhook, - rateLimit: rateLimiter, + lo: lo, + fs: fs, + sla: sla, + oidc: oidc, + i18n: i18n, + auth: auth, + media: media, + setting: settings, + inbox: inbox, + user: user, + team: team, + status: status, + priority: priority, + tmpl: template, + notifier: notifier, + consts: atomic.Value{}, + conversation: conversation, + automation: automation, + businessHours: businessHours, + activityLog: initActivityLog(db, i18n), + customAttribute: initCustomAttribute(db, i18n), + authz: initAuthz(i18n), + view: initView(db, i18n), + report: initReport(db, i18n), + csat: initCSAT(db, i18n), + search: initSearch(db, i18n), + role: initRole(db, i18n), + tag: initTag(db, i18n), + macro: initMacro(db, i18n), + ai: initAI(db, i18n), + importer: initImporter(i18n), + webhook: webhook, + rateLimit: rateLimiter, + redis: rdb, + userNotification: userNotification, } app.consts.Store(constants) @@ -309,6 +330,8 @@ func main() { conversation.Close() colorlog.Red("Shutting down SLA...") sla.Close() + colorlog.Red("Shutting down importer...") + app.importer.Close() colorlog.Red("Shutting down database...") db.Close() colorlog.Red("Shutting down redis...") diff --git a/cmd/media.go b/cmd/media.go index f3764649..e022f8dc 100644 --- a/cmd/media.go +++ b/cmd/media.go @@ -13,6 +13,7 @@ import ( amodels "github.com/abhinavxd/libredesk/internal/auth/models" "github.com/abhinavxd/libredesk/internal/envelope" "github.com/abhinavxd/libredesk/internal/image" + mmodels "github.com/abhinavxd/libredesk/internal/media/models" "github.com/abhinavxd/libredesk/internal/stringutil" "github.com/google/uuid" "github.com/valyala/fasthttp" @@ -20,10 +21,6 @@ import ( "github.com/zerodha/fastglue" ) -const ( - thumbPrefix = "thumb_" -) - // handleMediaUpload handles media uploads. func handleMediaUpload(r *fastglue.Request) error { var ( @@ -70,6 +67,12 @@ func handleMediaUpload(r *fastglue.Request) error { srcFileSize := fileHeader.Size srcExt := strings.TrimPrefix(strings.ToLower(filepath.Ext(srcFileName)), ".") + // Check if file is empty + if srcFileSize == 0 { + app.lo.Error("error: uploaded file is empty (0 bytes)") + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("media.fileEmpty"), nil, envelope.InputError) + } + // Check file size consts := app.consts.Load().(*constants) if bytesToMegabytes(srcFileSize) > float64(consts.MaxFileUploadSizeMB) { @@ -88,7 +91,7 @@ func handleMediaUpload(r *fastglue.Request) error { // Delete files on any error. var uuid = uuid.New() - thumbName := thumbPrefix + uuid.String() + thumbName := image.ThumbPrefix + uuid.String() defer func() { if cleanUp { app.media.Delete(uuid.String()) @@ -105,7 +108,7 @@ func handleMediaUpload(r *fastglue.Request) error { app.lo.Error("error creating thumb image", "error", err) return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.thumbnail}"), nil, envelope.GeneralError) } - thumbName, err = app.media.Upload(thumbName, srcContentType, thumbFile) + thumbName, _, err = app.media.Upload(thumbName, srcContentType, thumbFile) if err != nil { return sendErrorEnvelope(r, err) } @@ -124,8 +127,11 @@ func handleMediaUpload(r *fastglue.Request) error { }) } + // Reset ptr. file.Seek(0, 0) - _, err = app.media.Upload(uuid.String(), srcContentType, file) + + // Override content type after upload (in case it was detected incorrectly). + _, srcContentType, err = app.media.Upload(uuid.String(), srcContentType, file) if err != nil { cleanUp = true app.lo.Error("error uploading file", "error", err) @@ -143,18 +149,42 @@ func handleMediaUpload(r *fastglue.Request) error { } // handleServeMedia serves uploaded media. -// Supports both authenticated agent access and unauthenticated access via signed URLs. +// Supports both authenticated access (with permission checks) and signed URL access (no permission checks). func handleServeMedia(r *fastglue.Request) error { var ( app = r.Context.(*App) uuid = r.RequestCtx.UserValue("uuid").(string) + authMethod = r.RequestCtx.UserValue("auth_method") ) - // Check if user is authenticated (agent access) - auser := r.RequestCtx.UserValue("user") - if auser != nil { - // Authenticated. - user, err := app.user.GetAgent(auser.(amodels.User).ID, "") + // If accessed via signed URL, skip permission checks and serve file directly. + if authMethod == "signed_url" { + return serveMediaFile(r, app, uuid, nil) + } + + // Session/API key authenticated - perform full permission check. + auser := r.RequestCtx.UserValue("user").(amodels.User) + + user, err := app.user.GetAgent(auser.ID, "") + if err != nil { + return sendErrorEnvelope(r, err) + } + + // Fetch media from DB. + media, err := getMediaByUUID(app, uuid) + if err != nil { + return sendErrorEnvelope(r, err) + } + + // Check if the user has permission to access the linked model. + allowed, err := app.authz.EnforceMediaAccess(user, media.Model.String) + if err != nil { + return sendErrorEnvelope(r, err) + } + + // For messages, check access to the conversation this message is part of. + if media.Model.String == "messages" { + conversation, err := app.conversation.GetConversationByMessageID(media.ModelID.Int) if err != nil { return sendErrorEnvelope(r, err) } @@ -187,13 +217,40 @@ func handleServeMedia(r *fastglue.Request) error { return r.SendErrorEnvelope(http.StatusUnauthorized, app.i18n.Ts("globals.messages.denied", "name", "{globals.terms.permission}"), nil, envelope.UnauthorizedError) } } - // If no authenticated user, the middleware has already verified the request signature serve the file. + + return serveMediaFile(r, app, uuid, &media) +} + +// serveMediaFile serves the actual file content based on the storage provider. +// If media is nil, it will be fetched from DB. +func serveMediaFile(r *fastglue.Request, app *App, uuid string, media *mmodels.Media) error { + // Fetch media metadata from DB if not provided. + if media == nil { + m, err := getMediaByUUID(app, uuid) + if err != nil { + return sendErrorEnvelope(r, err) + } + media = &m + } + consts := app.consts.Load().(*constants) switch consts.UploadProvider { case "fs": + disposition := "attachment" + + // Keep certain content types inline. + if strings.HasPrefix(media.ContentType, "image/") || + strings.HasPrefix(media.ContentType, "video/") || + media.ContentType == "application/pdf" { + disposition = "inline" + } + + r.RequestCtx.Response.Header.Set("Content-Type", media.ContentType) + r.RequestCtx.Response.Header.Set("Content-Disposition", fmt.Sprintf(`%s; filename="%s"`, disposition, media.Filename)) + fasthttp.ServeFile(r.RequestCtx, filepath.Join(ko.String("upload.fs.upload_path"), uuid)) case "s3": - r.RequestCtx.Redirect(app.media.GetURL(uuid), http.StatusFound) + r.RequestCtx.Redirect(app.media.GetURL(uuid, media.ContentType, media.Filename), http.StatusFound) } return nil } @@ -202,3 +259,8 @@ func handleServeMedia(r *fastglue.Request) error { func bytesToMegabytes(bytes int64) float64 { return float64(bytes) / 1024 / 1024 } + +// getMediaByUUID fetches media metadata from DB, handling thumbnail prefix. +func getMediaByUUID(app *App, uuid string) (mmodels.Media, error) { + return app.media.Get(0, strings.TrimPrefix(uuid, image.ThumbPrefix)) +} diff --git a/cmd/messages.go b/cmd/messages.go index 414ea2b9..23d24f7d 100644 --- a/cmd/messages.go +++ b/cmd/messages.go @@ -1,7 +1,6 @@ package main import ( - "strconv" "strings" amodels "github.com/abhinavxd/libredesk/internal/auth/models" @@ -15,25 +14,38 @@ import ( ) 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"` + 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"` } // 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) - page, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page"))) - pageSize, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page_size"))) - total = 0 + 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.GetAgent(auser.ID, "") if err != nil { @@ -46,7 +58,7 @@ func handleGetMessages(r *fastglue.Request) error { return sendErrorEnvelope(r, err) } - messages, pageSize, err := app.conversation.GetConversationMessages(uuid, []string{cmodels.MessageIncoming, cmodels.MessageOutgoing, cmodels.MessageActivity}, nil, page, pageSize) + messages, pageSize, err := app.conversation.GetConversationMessages(uuid, page, pageSize, private, msgTypes) if err != nil { return sendErrorEnvelope(r, err) } @@ -55,7 +67,8 @@ func handleGetMessages(r *fastglue.Request) error { total = messages[i].Total // Populate attachment URLs for j := range messages[i].Attachments { - messages[i].Attachments[j].URL = app.media.GetURL(messages[i].Attachments[j].UUID) + att := messages[i].Attachments[j] + messages[i].Attachments[j].URL = app.media.GetURL(att.UUID, att.ContentType, att.Name) } } @@ -101,7 +114,8 @@ func handleGetMessage(r *fastglue.Request) error { message = messages[0] for j := range message.Attachments { - message.Attachments[j].URL = app.media.GetURL(message.Attachments[j].UUID) + att := message.Attachments[j] + message.Attachments[j].URL = app.media.GetURL(att.UUID, att.ContentType, att.Name) } return r.SendEnvelope(message) @@ -200,6 +214,11 @@ func handleSendMessage(r *fastglue.Request) error { app.lo.Error("error fetching media", "error", err) return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.media}"), nil, envelope.GeneralError) } + if m.ModelID.Int > 0 { + // Attachment is already associated with another model. Skip it. + app.lo.Warn("attachment already associated with another model, skipping", "media_id", m.ID, "model", m.Model.String, "model_id", m.ModelID.Int) + continue + } media = append(media, m) } @@ -214,7 +233,7 @@ func handleSendMessage(r *fastglue.Request) error { // Send private note. if req.Private { - message, err := app.conversation.SendPrivateNote(media, user.ID, cuuid, req.Message) + message, err := app.conversation.SendPrivateNote(media, user.ID, cuuid, req.Message, req.Mentions) if err != nil { return sendErrorEnvelope(r, err) } diff --git a/cmd/middlewares.go b/cmd/middlewares.go index 4b6d9ae2..011d602a 100644 --- a/cmd/middlewares.go +++ b/cmd/middlewares.go @@ -2,6 +2,7 @@ package main import ( "net/http" + "strconv" "strings" amodels "github.com/abhinavxd/libredesk/internal/auth/models" @@ -237,3 +238,61 @@ func notAuthPage(handler fastglue.FastRequestHandler) fastglue.FastRequestHandle return handler(r) } } + +// authOrSignedURL allows access if user is authenticated OR if URL has valid signature. +// Used for media endpoints that support both access methods. +func authOrSignedURL(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler { + return func(r *fastglue.Request) error { + app := r.Context.(*App) + + // First, try to authenticate normally. + user, err := authenticateUser(r, app) + if err == nil && user.ID > 0 { + // User is authenticated, set user context and proceed. + r.RequestCtx.SetUserValue("user", amodels.User{ + ID: user.ID, + Email: user.Email.String, + FirstName: user.FirstName, + LastName: user.LastName, + }) + r.RequestCtx.SetUserValue("auth_method", "session") + return handler(r) + } + + // Authentication failed, check for signed URL. + validator := app.media.SignedURLValidator() + if validator == nil { + // Store doesn't support signed URLs, require auth. + return r.SendErrorEnvelope(http.StatusUnauthorized, + app.i18n.T("auth.invalidOrExpiredSession"), nil, envelope.GeneralError) + } + + // Parse signature and expiry from query params. + sig := string(r.RequestCtx.QueryArgs().Peek("sig")) + expStr := string(r.RequestCtx.QueryArgs().Peek("exp")) + + if sig == "" || expStr == "" { + return r.SendErrorEnvelope(http.StatusUnauthorized, + app.i18n.T("auth.invalidOrExpiredSession"), nil, envelope.GeneralError) + } + + exp, err := strconv.ParseInt(expStr, 10, 64) + if err != nil { + return r.SendErrorEnvelope(http.StatusBadRequest, + app.i18n.Ts("globals.messages.invalid", "name", "expiry"), nil, envelope.InputError) + } + + // Get the UUID from the route. + uuid := r.RequestCtx.UserValue("uuid").(string) + + // Validate signature. + if !validator(uuid, sig, exp) { + return r.SendErrorEnvelope(http.StatusForbidden, + app.i18n.T("media.invalidOrExpiredURL"), nil, envelope.PermissionError) + } + + // Mark as signed URL access (no user context). + r.RequestCtx.SetUserValue("auth_method", "signed_url") + return handler(r) + } +} diff --git a/cmd/oauth.go b/cmd/oauth.go new file mode 100644 index 00000000..e3dc9984 --- /dev/null +++ b/cmd/oauth.go @@ -0,0 +1,440 @@ +package main + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "github.com/abhinavxd/libredesk/internal/envelope" + "github.com/abhinavxd/libredesk/internal/inbox" + "github.com/abhinavxd/libredesk/internal/inbox/channel/email/oauth" + imodels "github.com/abhinavxd/libredesk/internal/inbox/models" + "github.com/abhinavxd/libredesk/internal/stringutil" + "github.com/valyala/fasthttp" + "github.com/zerodha/fastglue" + "golang.org/x/oauth2" +) + +const ( + FlowTypeNewInbox = "new_inbox" + FlowTypeReconnect = "reconnect" +) + +// OAuthCredentialsRequest represents the OAuth credentials from the request body. +type OAuthCredentialsRequest struct { + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` + TenantID string `json:"tenant_id,omitempty"` // Optional for Microsoft + FlowType string `json:"flow_type,omitempty"` // "new_inbox" or "reconnect" + InboxID int `json:"inbox_id,omitempty"` // Required for reconnect flow +} + +// handleOAuthAuthorize initiates the OAuth authorization flow for creating a new email inbox. +func handleOAuthAuthorize(r *fastglue.Request) error { + var ( + app = r.Context.(*App) + provider = r.RequestCtx.UserValue("provider").(string) + req OAuthCredentialsRequest + ) + + if provider != string(oauth.ProviderGoogle) && provider != string(oauth.ProviderMicrosoft) { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, + app.i18n.Ts("globals.messages.somethingWentWrong"), nil, envelope.InputError) + } + + // Parse request body + if err := json.Unmarshal(r.RequestCtx.PostBody(), &req); err != nil { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, + app.i18n.Ts("globals.messages.somethingWentWrong"), nil, envelope.InputError) + } + + // Validate credentials + if req.ClientID == "" || req.ClientSecret == "" { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.badRequest"), nil, envelope.InputError) + } + + if req.FlowType != FlowTypeNewInbox && req.FlowType != FlowTypeReconnect { + req.FlowType = FlowTypeNewInbox + } + + // Build redirect URI + redirectURI := app.consts.Load().(*constants).AppBaseURL + "/api/v1/inboxes/oauth/" + provider + "/callback" + + // Generate secure random state + state, err := stringutil.RandomAlphanumeric(32) + if err != nil { + app.lo.Error("Failed to generate OAuth state", "error", err) + return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorGenerating", "name", "state"), nil, envelope.GeneralError) + } + + // Store OAuth data in Redis with 15 min expiry + redisKey := "inbox_oauth:" + state + oauthData := map[string]any{ + "provider": provider, + "redirect_uri": redirectURI, + "client_id": req.ClientID, + "client_secret": req.ClientSecret, + "flow_type": req.FlowType, + "inbox_id": req.InboxID, + } + + // Add tenant ID for Microsoft if provided + if provider == string(oauth.ProviderMicrosoft) && req.TenantID != "" { + oauthData["tenant_id"] = req.TenantID + } + + if err := app.redis.HSet(ctx, redisKey, oauthData).Err(); err != nil { + app.lo.Error("Failed to store OAuth state in Redis", "error", err) + return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.somethingWentWrong"), nil, envelope.GeneralError) + } + + // Set 15 min expiry (auto-cleanup) + if err := app.redis.Expire(ctx, redisKey, 15*time.Minute).Err(); err != nil { + app.lo.Error("Failed to set expiry for OAuth state in Redis", "error", err) + return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.somethingWentWrong"), nil, envelope.GeneralError) + } + + // Build authorization URL with scopes + authURL, err := oauth.BuildAuthorizationURL( + oauth.Provider(provider), + req.ClientID, + redirectURI, + state, + req.TenantID, + ) + if err != nil { + app.lo.Error("Failed to build authorization URL", "error", err) + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, err.Error(), nil, envelope.InputError) + } + + return r.SendEnvelope(authURL) +} + +// handleOAuthCallback handles the OAuth callback and auto-creates an inbox. +func handleOAuthCallback(r *fastglue.Request) error { + var ( + app = r.Context.(*App) + provider = r.RequestCtx.UserValue("provider").(string) + code = string(r.RequestCtx.QueryArgs().Peek("code")) + state = string(r.RequestCtx.QueryArgs().Peek("state")) + ) + + // Check if user denied authorization + if code == "" { + errorMsg := string(r.RequestCtx.QueryArgs().Peek("error")) + errorDesc := string(r.RequestCtx.QueryArgs().Peek("error_description")) + app.lo.Error("OAuth authorization failed", "error", errorMsg, "description", errorDesc) + return r.Redirect("/admin/inboxes?error=oauth_denied", fasthttp.StatusFound, nil, "") + } + + if state == "" { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.somethingWentWrong"), nil, envelope.InputError) + } + + // Retrieve OAuth data from Redis + redisKey := "inbox_oauth:" + state + oauthData, err := app.redis.HGetAll(ctx, redisKey).Result() + if err != nil || len(oauthData) == 0 { + app.lo.Error("Invalid or expired OAuth state", "error", err, "state", state) + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, + app.i18n.Ts("globals.messages.somethingWentWrong"), nil, envelope.InputError) + } + + // Delete key after retrieval (one-time use) + app.redis.Del(ctx, redisKey) + + // Extract values from Redis hash + storedProvider := oauthData["provider"] + redirectURI := oauthData["redirect_uri"] + clientID := oauthData["client_id"] + clientSecret := oauthData["client_secret"] + tenantID := oauthData["tenant_id"] // Empty string if not set + flowType := oauthData["flow_type"] // "new_inbox" or "reconnect" + inboxIDStr := oauthData["inbox_id"] // Inbox ID for reconnect flow + + // Validate provider matches URL parameter + if storedProvider != provider { + app.lo.Error("Provider mismatch", "stored", storedProvider, "url", provider) + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.somethingWentWrong"), nil, envelope.InputError) + } + + // Exchange authorization code for tokens + token, err := oauth.ExchangeCodeForToken( + context.Background(), + oauth.Provider(provider), + clientID, + clientSecret, + code, + redirectURI, + tenantID, + ) + if err != nil { + app.lo.Error("Failed to exchange code for tokens", "error", err) + return r.Redirect("/admin/inboxes?error=token_exchange_failed", fasthttp.StatusFound, nil, "") + } + + // Get user email from provider + userEmail, err := getUserEmailFromProvider(provider, token) + if err != nil { + app.lo.Error("Failed to get user email from provider", "error", err) + return r.Redirect("/admin/inboxes?error=email_fetch_failed", fasthttp.StatusFound, nil, "") + } + + if userEmail == "" { + app.lo.Error("User email not found from provider") + return r.Redirect("/admin/inboxes?error=email_fetch_failed", fasthttp.StatusFound, nil, "") + } + + // Check if inbox with this email already exists + existingInboxes, err := app.inbox.GetAll() + if err != nil { + return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.somethingWentWrong"), nil, envelope.GeneralError) + } + + // Extract email address for comparison (handles "Name " format) + userEmailAddr, err := stringutil.ExtractEmail(userEmail) + if err != nil { + app.lo.Error("error extracting email address", "email", userEmail, "error", err) + // Fallback + userEmailAddr = userEmail + } + + var existingInbox *imodels.Inbox + for i, existing := range existingInboxes { + existingEmailAddr, err := stringutil.ExtractEmail(existing.From) + if err != nil { + existingEmailAddr = existing.From + } + + if existingEmailAddr == userEmailAddr { + existingInbox = &existingInboxes[i] + break + } + } + + // Validate flow type matches actual state + // New inbox flow: reject if inbox already exists + if flowType == FlowTypeNewInbox && existingInbox != nil { + app.lo.Error("Inbox already exists for email", "email", userEmail) + return r.Redirect("/admin/inboxes?error=inbox_already_exists", fasthttp.StatusFound, nil, "") + } + + // Reconnect flow: validate inbox_id and email match + if flowType == FlowTypeReconnect { + // Find the target inbox by ID + var targetInbox *imodels.Inbox + for i, existing := range existingInboxes { + if fmt.Sprintf("%d", existing.ID) == inboxIDStr { + targetInbox = &existingInboxes[i] + break + } + } + + if targetInbox == nil { + app.lo.Error("Target inbox not found for reconnect", "inbox_id", inboxIDStr) + return r.Redirect("/admin/inboxes?error=inbox_not_found", fasthttp.StatusFound, nil, "") + } + + // Verify the authorized email matches the target inbox's email + targetEmailAddr, _ := stringutil.ExtractEmail(targetInbox.From) + if targetEmailAddr != userEmailAddr { + app.lo.Error("Email mismatch during reconnect", "target_email", targetEmailAddr, "authorized_email", userEmailAddr) + return r.Redirect("/admin/inboxes?error=email_mismatch", fasthttp.StatusFound, nil, "") + } + + existingInbox = targetInbox + } + + // If inbox exists, update it with new OAuth tokens (reconnect flow) + if existingInbox != nil { + app.lo.Info("Updating existing inbox with new OAuth tokens", "email", userEmail, "inbox_id", existingInbox.ID) + + // Parse existing config + var existingConfig imodels.Config + if err := json.Unmarshal(existingInbox.Config, &existingConfig); err != nil { + app.lo.Error("Failed to unmarshal existing config", "error", err) + return r.Redirect("/admin/inboxes?error=config_parse_failed", fasthttp.StatusFound, nil, "") + } + + // Update OAuth section with new tokens + oauthConfig := &imodels.OAuthConfig{ + Provider: provider, + AccessToken: token.AccessToken, + RefreshToken: token.RefreshToken, + ExpiresAt: token.Expiry, + ClientID: clientID, + ClientSecret: clientSecret, + TenantID: tenantID, + } + existingConfig.OAuth = oauthConfig + existingConfig.AuthType = imodels.AuthTypeOAuth2 + + // Marshal updated config + configJSON, err := json.Marshal(existingConfig) + if err != nil { + app.lo.Error("Failed to marshal updated config", "error", err) + return r.Redirect("/admin/inboxes?error=config_update_failed", fasthttp.StatusFound, nil, "") + } + + // Update inbox config directly (bypasses preservation logic that could corrupt OAuth tokens) + if err := app.inbox.UpdateConfig(existingInbox.ID, json.RawMessage(configJSON)); err != nil { + app.lo.Error("Failed to update inbox config", "error", err) + return r.Redirect("/admin/inboxes?error=inbox_update_failed", fasthttp.StatusFound, nil, "") + } + + // Reload inboxes to apply new tokens + if err := reloadInboxes(app); err != nil { + app.lo.Error("Failed to reload inboxes", "error", err) + } + + return r.Redirect("/admin/inboxes?success=oauth_reconnected", fasthttp.StatusFound, nil, "") + } + + // Get provider-specific defaults + smtpConfig, imapConfig := getProviderDefaults(provider, userEmail) + + // Create OAuth config for tokens + oauthConfig := &imodels.OAuthConfig{ + Provider: provider, + AccessToken: token.AccessToken, + RefreshToken: token.RefreshToken, + ExpiresAt: token.Expiry, + ClientID: clientID, + ClientSecret: clientSecret, + TenantID: tenantID, + } + + // Create inbox config + config := imodels.Config{ + SMTP: []imodels.SMTPConfig{smtpConfig}, + IMAP: []imodels.IMAPConfig{imapConfig}, + From: userEmail, + AuthType: imodels.AuthTypeOAuth2, + OAuth: oauthConfig, + } + + configJSON, err := json.Marshal(config) + if err != nil { + app.lo.Error("Failed to marshal inbox config", "error", err) + return r.Redirect("/admin/inboxes?error=config_creation_failed", fasthttp.StatusFound, nil, "") + } + + // Create inbox + newInbox := imodels.Inbox{ + Name: fmt.Sprintf("%s Inbox", userEmail), + From: userEmail, + Channel: inbox.ChannelEmail, + Enabled: true, + CSATEnabled: false, + Config: json.RawMessage(configJSON), + } + + _, err = app.inbox.Create(newInbox) + if err != nil { + app.lo.Error("Failed to create inbox", "error", err) + return r.Redirect("/admin/inboxes?error=inbox_creation_failed", fasthttp.StatusFound, nil, "") + } + + // Reload inboxes to start the new inbox + if err := reloadInboxes(app); err != nil { + app.lo.Error("Failed to reload inboxes", "error", err) + } + + return r.Redirect("/admin/inboxes?success=oauth_connected", fasthttp.StatusFound, nil, "") +} + +// getUserEmailFromProvider fetches the user's email from the OAuth provider. +func getUserEmailFromProvider(provider string, token *oauth2.Token) (string, error) { + switch provider { + case string(oauth.ProviderMicrosoft): + idToken, ok := token.Extra("id_token").(string) + if !ok { + return "", fmt.Errorf("no id_token") + } + + parts := strings.Split(idToken, ".") + if len(parts) < 2 { + return "", fmt.Errorf("invalid id_token") + } + + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return "", err + } + + var claims map[string]any + json.Unmarshal(payload, &claims) + + if email, ok := claims["email"].(string); ok && email != "" { + return email, nil + } + if upn, ok := claims["preferred_username"].(string); ok { + return upn, nil + } + return "", fmt.Errorf("email not found") + case string(oauth.ProviderGoogle): + req, _ := http.NewRequest("GET", "https://www.googleapis.com/oauth2/v2/userinfo", nil) + req.Header.Set("Authorization", "Bearer "+token.AccessToken) + + resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + + if email, ok := result["email"].(string); ok { + return email, nil + } + return "", fmt.Errorf("email not found") + } + + return "", fmt.Errorf("unsupported provider") +} + +// getProviderDefaults returns provider-specific SMTP and IMAP configurations. +func getProviderDefaults(provider, emailAddr string) (imodels.SMTPConfig, imodels.IMAPConfig) { + var smtp imodels.SMTPConfig + var imap imodels.IMAPConfig + + // Common settings + smtp.Username = emailAddr + smtp.AuthProtocol = "login" + smtp.TLSSkipVerify = false + smtp.MaxConns = 10 + smtp.MaxMessageRetries = 2 + smtp.IdleTimeout = "20s" + smtp.PoolWaitTimeout = "30s" + + imap.Username = emailAddr + imap.Mailbox = "INBOX" + imap.ReadInterval = "5m" + imap.ScanInboxSince = "24h" + imap.TLSSkipVerify = false + + // Provider-specific settings + switch provider { + case string(oauth.ProviderGoogle): + smtp.Host = "smtp.gmail.com" + smtp.Port = 587 + smtp.TLSType = "starttls" + imap.Host = "imap.gmail.com" + imap.Port = 993 + imap.TLSType = "tls" + case string(oauth.ProviderMicrosoft): + smtp.Host = "smtp.office365.com" + smtp.Port = 587 + smtp.TLSType = "starttls" + imap.Host = "outlook.office365.com" + imap.Port = 993 + imap.TLSType = "tls" + } + + return smtp, imap +} diff --git a/cmd/oidc.go b/cmd/oidc.go index bb2dcdbb..dfdb1743 100644 --- a/cmd/oidc.go +++ b/cmd/oidc.go @@ -2,11 +2,9 @@ package main import ( "strconv" - "strings" "github.com/abhinavxd/libredesk/internal/envelope" "github.com/abhinavxd/libredesk/internal/oidc/models" - "github.com/abhinavxd/libredesk/internal/stringutil" "github.com/valyala/fasthttp" "github.com/zerodha/fastglue" ) @@ -20,7 +18,7 @@ func handleGetAllOIDC(r *fastglue.Request) error { } // Replace secrets with dummy values. for i := range out { - out[i].ClientSecret = strings.Repeat(stringutil.PasswordDummy, 10) + out[i].ClearSecrets() } return r.SendEnvelope(out) } @@ -30,13 +28,13 @@ func handleGetOIDC(r *fastglue.Request) error { var app = r.Context.(*App) id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string)) if err != nil || id <= 0 { - return r.SendErrorEnvelope(fasthttp.StatusBadRequest, - app.i18n.Ts("globals.messages.invalid", "name", "OIDC `id`"), nil, envelope.InputError) + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError) } - o, err := app.oidc.Get(id, false) + o, err := app.oidc.Get(id) if err != nil { return sendErrorEnvelope(r, err) } + o.ClearSecrets() return r.SendEnvelope(o) } @@ -66,7 +64,7 @@ func handleCreateOIDC(r *fastglue.Request) error { } // Clear client secret before returning - createdOIDC.ClientSecret = strings.Repeat(stringutil.PasswordDummy, 10) + createdOIDC.ClearSecrets() return r.SendEnvelope(createdOIDC) } @@ -79,7 +77,7 @@ func handleUpdateOIDC(r *fastglue.Request) error { ) id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string)) if err != nil || id == 0 { - return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "OIDC `id`"), nil, envelope.InputError) + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError) } if err := r.Decode(&req, "json"); err != nil { @@ -102,7 +100,7 @@ func handleUpdateOIDC(r *fastglue.Request) error { } // Clear client secret before returning - updatedOIDC.ClientSecret = strings.Repeat(stringutil.PasswordDummy, 10) + updatedOIDC.ClearSecrets() return r.SendEnvelope(updatedOIDC) } @@ -112,7 +110,7 @@ func handleDeleteOIDC(r *fastglue.Request) error { var app = r.Context.(*App) id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string)) if err != nil || id == 0 { - return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "OIDC `id`"), nil, envelope.InputError) + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError) } if err = app.oidc.Delete(id); err != nil { return sendErrorEnvelope(r, err) diff --git a/cmd/report.go b/cmd/report.go index b267c7de..f0dec2ef 100644 --- a/cmd/report.go +++ b/cmd/report.go @@ -43,3 +43,42 @@ func handleOverviewSLA(r *fastglue.Request) error { } return r.SendEnvelope(sla) } + +// handleOverviewCSAT retrieves CSAT metrics for the dashboard. +func handleOverviewCSAT(r *fastglue.Request) error { + var ( + app = r.Context.(*App) + days, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("days"))) + ) + csat, err := app.report.GetOverviewCSAT(days) + if err != nil { + return sendErrorEnvelope(r, err) + } + return r.SendEnvelope(csat) +} + +// handleOverviewMessageVolume retrieves message volume metrics for the dashboard. +func handleOverviewMessageVolume(r *fastglue.Request) error { + var ( + app = r.Context.(*App) + days, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("days"))) + ) + volume, err := app.report.GetOverviewMessageVolume(days) + if err != nil { + return sendErrorEnvelope(r, err) + } + return r.SendEnvelope(volume) +} + +// handleOverviewTagDistribution retrieves tag distribution metrics for the dashboard. +func handleOverviewTagDistribution(r *fastglue.Request) error { + var ( + app = r.Context.(*App) + days, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("days"))) + ) + tags, err := app.report.GetOverviewTagDistribution(days) + if err != nil { + return sendErrorEnvelope(r, err) + } + return r.SendEnvelope(tags) +} diff --git a/cmd/roles.go b/cmd/roles.go index c7726a92..7c4d717a 100644 --- a/cmd/roles.go +++ b/cmd/roles.go @@ -3,8 +3,11 @@ package main import ( "strconv" + amodels "github.com/abhinavxd/libredesk/internal/auth/models" "github.com/abhinavxd/libredesk/internal/envelope" + "github.com/abhinavxd/libredesk/internal/role" "github.com/abhinavxd/libredesk/internal/role/models" + realip "github.com/ferluci/fast-realip" "github.com/valyala/fasthttp" "github.com/zerodha/fastglue" ) @@ -43,6 +46,11 @@ func handleDeleteRole(r *fastglue.Request) error { if err := app.role.Delete(id); err != nil { return sendErrorEnvelope(r, err) } + + // Invalidate all caches when role is deleted. + app.user.InvalidateAllAgentCache() + app.authz.InvalidateAllCache() + return r.SendEnvelope(true) } @@ -66,15 +74,37 @@ func handleCreateRole(r *fastglue.Request) error { func handleUpdateRole(r *fastglue.Request) error { var ( app = r.Context.(*App) + auser = r.RequestCtx.UserValue("user").(amodels.User) + ip = realip.FromRequest(r.RequestCtx) id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string)) req = models.Role{} ) if err := r.Decode(&req, "json"); err != nil { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError) } + + // Get old role before update to compare permissions. + oldRole, err := app.role.Get(id) + if err != nil { + return sendErrorEnvelope(r, err) + } + updatedRole, err := app.role.Update(id, req) if err != nil { return sendErrorEnvelope(r, err) } + + // Log permission changes and invalidate caches. + added, removed := role.ComparePermissions(oldRole.Permissions, updatedRole.Permissions) + if len(added) > 0 || len(removed) > 0 { + // Invalidate all caches when role permissions change. + app.user.InvalidateAllAgentCache() + app.authz.InvalidateAllCache() + + if err := app.activityLog.RolePermissionsChanged(auser.ID, auser.Email, ip, updatedRole.ID, updatedRole.Name, added, removed); err != nil { + app.lo.Error("error creating activity log", "error", err) + } + } + return r.SendEnvelope(updatedRole) } diff --git a/cmd/settings.go b/cmd/settings.go index 33d7b7e0..264c4997 100644 --- a/cmd/settings.go +++ b/cmd/settings.go @@ -127,7 +127,7 @@ func handleUpdateEmailNotificationSettings(r *fastglue.Request) error { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.invalidFromAddress"), nil, envelope.InputError) } - // If empty then retain previous password. + // Retain current password if not changed. if req.Password == "" { req.Password = cur.Password } diff --git a/cmd/upgrade.go b/cmd/upgrade.go index ccf92697..eb8ffe44 100644 --- a/cmd/upgrade.go +++ b/cmd/upgrade.go @@ -37,6 +37,9 @@ var migList = []migFunc{ {"v0.7.0", migrations.V0_7_0}, {"v0.7.4", migrations.V0_7_4}, {"v0.9.0", migrations.V0_9_0}, + {"v0.8.5", migrations.V0_8_5}, + {"v0.9.1", migrations.V0_9_1}, + {"v0.10.0", migrations.V0_10_0}, } // upgrade upgrades the database to the current version by running SQL migration files diff --git a/cmd/user_notifications.go b/cmd/user_notifications.go new file mode 100644 index 00000000..902b47d7 --- /dev/null +++ b/cmd/user_notifications.go @@ -0,0 +1,99 @@ +package main + +import ( + "strconv" + + amodels "github.com/abhinavxd/libredesk/internal/auth/models" + "github.com/abhinavxd/libredesk/internal/envelope" + "github.com/valyala/fasthttp" + "github.com/zerodha/fastglue" +) + +func handleGetUserNotifications(r *fastglue.Request) error { + var ( + app = r.Context.(*App) + auser = r.RequestCtx.UserValue("user").(amodels.User) + limit = 20 + offset = 0 + ) + if l := r.RequestCtx.QueryArgs().GetUintOrZero("limit"); l > 0 && l <= 100 { + limit = l + } + if o := r.RequestCtx.QueryArgs().GetUintOrZero("offset"); o > 0 { + offset = o + } + notifications, err := app.userNotification.GetAll(auser.ID, limit, offset) + if err != nil { + return sendErrorEnvelope(r, err) + } + return r.SendEnvelope(notifications) +} + +func handleGetUserNotificationStats(r *fastglue.Request) error { + var ( + app = r.Context.(*App) + auser = r.RequestCtx.UserValue("user").(amodels.User) + ) + stats, err := app.userNotification.GetStats(auser.ID) + if err != nil { + return sendErrorEnvelope(r, err) + } + return r.SendEnvelope(stats) +} + +func handleMarkNotificationAsRead(r *fastglue.Request) error { + var ( + app = r.Context.(*App) + auser = r.RequestCtx.UserValue("user").(amodels.User) + id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string)) + ) + if id <= 0 { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, + app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError) + } + if err := app.userNotification.MarkAsRead(id, auser.ID); err != nil { + return sendErrorEnvelope(r, err) + } + return r.SendEnvelope(true) +} + +func handleMarkAllNotificationsAsRead(r *fastglue.Request) error { + var ( + app = r.Context.(*App) + auser = r.RequestCtx.UserValue("user").(amodels.User) + ) + + if err := app.userNotification.MarkAllAsRead(auser.ID); err != nil { + return sendErrorEnvelope(r, err) + } + return r.SendEnvelope(true) +} + +func handleDeleteNotification(r *fastglue.Request) error { + var ( + app = r.Context.(*App) + auser = r.RequestCtx.UserValue("user").(amodels.User) + id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string)) + ) + if id <= 0 { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, + app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError) + } + + if err := app.userNotification.Delete(id, auser.ID); err != nil { + return sendErrorEnvelope(r, err) + } + return r.SendEnvelope(true) +} + +func handleDeleteAllNotifications(r *fastglue.Request) error { + var ( + app = r.Context.(*App) + auser = r.RequestCtx.UserValue("user").(amodels.User) + ) + + if err := app.userNotification.DeleteAll(auser.ID); err != nil { + return sendErrorEnvelope(r, err) + } + return r.SendEnvelope(true) +} diff --git a/cmd/users.go b/cmd/users.go index 7c0145d1..9588337c 100644 --- a/cmd/users.go +++ b/cmd/users.go @@ -282,6 +282,13 @@ func handleUpdateAgent(r *fastglue.Request) error { } } + // Log activity if password was changed. + if req.NewPassword != "" { + if err := app.activityLog.PasswordSet(auser.ID, auser.Email, ip, id, req.Email); err != nil { + app.lo.Error("error creating activity log", "error", err) + } + } + // Upsert agent teams. if err := app.team.UpsertUserTeams(id, req.Teams); err != nil { return sendErrorEnvelope(r, err) diff --git a/cmd/views.go b/cmd/views.go index 71885fd0..15e13eec 100644 --- a/cmd/views.go +++ b/cmd/views.go @@ -10,7 +10,7 @@ import ( "github.com/zerodha/fastglue" ) -// handleGetUserViews returns all views for a user. +// handleGetUserViews returns all personal views for a user. func handleGetUserViews(r *fastglue.Request) error { var ( app = r.Context.(*App) @@ -27,7 +27,7 @@ func handleGetUserViews(r *fastglue.Request) error { return r.SendEnvelope(v) } -// handleCreateUserView creates a view for a user. +// handleCreateUserView creates a personal view for a user. func handleCreateUserView(r *fastglue.Request) error { var ( app = r.Context.(*App) @@ -54,7 +54,7 @@ func handleCreateUserView(r *fastglue.Request) error { return r.SendEnvelope(createdView) } -// handleDeleteUserView deletes a view for a user. +// handleDeleteUserView deletes a personal view for a user. func handleDeleteUserView(r *fastglue.Request) error { var ( app = r.Context.(*App) @@ -72,8 +72,9 @@ func handleDeleteUserView(r *fastglue.Request) error { if err != nil { return sendErrorEnvelope(r, err) } - if view.UserID != user.ID { - return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.Ts("globals.messages.denied", "name", "{globals.terms.permission}"), nil, envelope.PermissionError) + // Only allow deletion of personal views owned by the user + if err := validatePersonalViewOwnership(app, view, user.ID); err != nil { + return sendErrorEnvelope(r, err) } if err = app.view.Delete(id); err != nil { return sendErrorEnvelope(r, err) @@ -81,7 +82,7 @@ func handleDeleteUserView(r *fastglue.Request) error { return r.SendEnvelope(true) } -// handleUpdateUserView updates a view for a user. +// handleUpdateUserView updates a personal view for a user. func handleUpdateUserView(r *fastglue.Request) error { var ( app = r.Context.(*App) @@ -109,12 +110,165 @@ func handleUpdateUserView(r *fastglue.Request) error { if err != nil { return sendErrorEnvelope(r, err) } - if v.UserID != user.ID { - return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.Ts("globals.messages.denied", "name", "{globals.terms.permission}"), nil, envelope.PermissionError) + // Only allow update of personal views owned by the user + if err := validatePersonalViewOwnership(app, v, user.ID); err != nil { + return sendErrorEnvelope(r, err) } - updatedView, err := app.view.Update(id, view.Name, view.Filters) + updatedView, err := app.view.Update(id, view.Name, view.Filters, user.ID) if err != nil { return sendErrorEnvelope(r, err) } return r.SendEnvelope(updatedView) } + +// handleGetSharedViews returns shared views accessible to the current user. +func handleGetSharedViews(r *fastglue.Request) error { + var ( + app = r.Context.(*App) + auser = r.RequestCtx.UserValue("user").(amodels.User) + ) + user, err := app.user.GetAgent(auser.ID, "") + if err != nil { + return sendErrorEnvelope(r, err) + } + views, err := app.view.GetSharedViewsForUser(user.Teams.IDs()) + if err != nil { + return sendErrorEnvelope(r, err) + } + return r.SendEnvelope(views) +} + +// handleGetAllSharedViews returns all shared views (admin only). +func handleGetAllSharedViews(r *fastglue.Request) error { + app := r.Context.(*App) + views, err := app.view.GetAllSharedViews() + if err != nil { + return sendErrorEnvelope(r, err) + } + return r.SendEnvelope(views) +} + +// handleGetSharedView returns a single shared view (admin only). +func handleGetSharedView(r *fastglue.Request) error { + var ( + app = r.Context.(*App) + id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string)) + ) + if id <= 0 { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError) + } + view, err := app.view.Get(id) + if err != nil { + return sendErrorEnvelope(r, err) + } + // Ensure it's a shared view (not a personal view) + if view.Visibility == vmodels.VisibilityUser { + return r.SendErrorEnvelope(fasthttp.StatusNotFound, app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.view}"), nil, envelope.NotFoundError) + } + return r.SendEnvelope(view) +} + +// handleCreateSharedView creates a shared view (admin only). +func handleCreateSharedView(r *fastglue.Request) error { + var ( + app = r.Context.(*App) + view = vmodels.View{} + ) + if err := r.Decode(&view, "json"); err != nil { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), err.Error(), envelope.InputError) + } + + // Validation + if err := validateSharedView(app, view); err != nil { + return sendErrorEnvelope(r, err) + } + + createdView, err := app.view.CreateSharedView(view.Name, view.Filters, view.Visibility, view.TeamID) + if err != nil { + return sendErrorEnvelope(r, err) + } + return r.SendEnvelope(createdView) +} + +// handleUpdateSharedView updates a shared view (admin only). +func handleUpdateSharedView(r *fastglue.Request) error { + var ( + app = r.Context.(*App) + view = vmodels.View{} + id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string)) + ) + if id <= 0 { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError) + } + if err := r.Decode(&view, "json"); err != nil { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), err.Error(), envelope.InputError) + } + + // Verify view exists and is shared + existingView, err := app.view.Get(id) + if err != nil { + return sendErrorEnvelope(r, err) + } + if existingView.Visibility == vmodels.VisibilityUser { + return r.SendErrorEnvelope(fasthttp.StatusNotFound, app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.view}"), nil, envelope.NotFoundError) + } + + // Validation + if err := validateSharedView(app, view); err != nil { + return sendErrorEnvelope(r, err) + } + + updatedView, err := app.view.UpdateSharedView(id, view.Name, view.Filters, view.Visibility, view.TeamID) + if err != nil { + return sendErrorEnvelope(r, err) + } + return r.SendEnvelope(updatedView) +} + +// handleDeleteSharedView deletes a shared view (admin only). +func handleDeleteSharedView(r *fastglue.Request) error { + app := r.Context.(*App) + id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string)) + if err != nil || id <= 0 { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError) + } + + // Verify view exists and is shared + existingView, err := app.view.Get(id) + if err != nil { + return sendErrorEnvelope(r, err) + } + if existingView.Visibility == vmodels.VisibilityUser { + return r.SendErrorEnvelope(fasthttp.StatusNotFound, app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.view}"), nil, envelope.NotFoundError) + } + + if err = app.view.Delete(id); err != nil { + return sendErrorEnvelope(r, err) + } + return r.SendEnvelope(true) +} + +// validatePersonalViewOwnership checks if the user owns the personal view. +func validatePersonalViewOwnership(app *App, view vmodels.View, userID int) error { + if view.UserID == nil || *view.UserID != userID || view.Visibility != vmodels.VisibilityUser { + return envelope.NewError(envelope.PermissionError, app.i18n.Ts("globals.messages.denied", "name", "{globals.terms.permission}"), nil) + } + return nil +} + +// validateSharedView validates the fields of a shared view. +func validateSharedView(app *App, view vmodels.View) error { + if view.Name == "" { + return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil) + } + if string(view.Filters) == "" { + return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`filters`"), nil) + } + if view.Visibility != vmodels.VisibilityAll && view.Visibility != vmodels.VisibilityTeam { + return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`visibility`"), nil) + } + if view.Visibility == vmodels.VisibilityTeam && (view.TeamID == nil || *view.TeamID <= 0) { + return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.required", "name", "`team_id`"), nil) + } + return nil +} diff --git a/cmd/webhooks.go b/cmd/webhooks.go index 22b4ecb9..5ffcd826 100644 --- a/cmd/webhooks.go +++ b/cmd/webhooks.go @@ -99,15 +99,6 @@ func handleUpdateWebhook(r *fastglue.Request) error { return r.SendEnvelope(err) } - // If secret is empty or contains dummy characters, fetch existing webhook and preserve the secret - if webhook.Secret == "" || strings.Contains(webhook.Secret, stringutil.PasswordDummy) { - existingWebhook, err := app.webhook.Get(id) - if err != nil { - return sendErrorEnvelope(r, err) - } - webhook.Secret = existingWebhook.Secret - } - updatedWebhook, err := app.webhook.Update(id, webhook) if err != nil { return sendErrorEnvelope(r, err) diff --git a/config.sample.toml b/config.sample.toml index 3fb1b5c9..55c2b603 100644 --- a/config.sample.toml +++ b/config.sample.toml @@ -6,6 +6,8 @@ log_level = "debug" env = "dev" # Whether to automatically check for application updates on start up, app updates are shown as a banner in the admin panel. check_updates = true +# Encryption key. Generate using `openssl rand -hex 16` must be 32 characters long. +encryption_key = "your-32-char-random-string-here!" # HTTP server. [app.server] @@ -76,8 +78,11 @@ max_lifetime = "300s" # Redis. [redis] +# url parameter overrides other redis config options +# url = "redis://user:password@host:port/db" # If running locally, use `localhost:6379`. address = "redis:6379" +user = "" password = "" db = 0 @@ -118,6 +123,8 @@ timeout = "15s" [conversation] # How often to check for conversations to unsnooze unsnooze_interval = "5m" +# How long to keep drafts before deleting them from the database. (e.g. "360h", "48h") +draft_retention_period = "360h" [conversation.continuity] offline_threshold = "10m" diff --git a/frontend/apps/main/src/App.vue b/frontend/apps/main/src/App.vue index 8547a638..865a76d3 100644 --- a/frontend/apps/main/src/App.vue +++ b/frontend/apps/main/src/App.vue @@ -1,8 +1,8 @@ diff --git a/frontend/apps/main/src/components/sidebar/NotificationPanel.vue b/frontend/apps/main/src/components/sidebar/NotificationPanel.vue new file mode 100644 index 00000000..c0060a51 --- /dev/null +++ b/frontend/apps/main/src/components/sidebar/NotificationPanel.vue @@ -0,0 +1,211 @@ + + + diff --git a/frontend/apps/main/src/components/sidebar/Sidebar.vue b/frontend/apps/main/src/components/sidebar/Sidebar.vue index 969a0134..d4be3e03 100644 --- a/frontend/apps/main/src/components/sidebar/Sidebar.vue +++ b/frontend/apps/main/src/components/sidebar/Sidebar.vue @@ -19,10 +19,9 @@ import { SidebarMenuItem, SidebarMenuSub, SidebarMenuSubItem, - SidebarProvider, - SidebarRail + SidebarProvider } from '@shared-ui/components/ui/sidebar' -import { useAppSettingsStore } from '../../stores/appSettings' +import { useAppSettingsStore } from '@main/stores/appSettings' import { ChevronRight, EllipsisVertical, @@ -30,7 +29,8 @@ import { Search, Plus, CircleDashed, - List + List, + AtSign } from 'lucide-vue-next' import { DropdownMenu, @@ -48,16 +48,18 @@ import { AlertDialogHeader, AlertDialogTitle } from '@shared-ui/components/ui/alert-dialog' -import { filterNavItems } from '@/utils/nav-permissions' +import { filterNavItems } from '@main/utils/nav-permissions' +import { permissions } from '@main/constants/permissions' import { useStorage } from '@vueuse/core' import { computed, ref, watch } from 'vue' import { useI18n } from 'vue-i18n' -import { useUserStore } from '../../stores/user' -import { useConversationStore } from '../../stores/conversation' +import { useUserStore } from '@main/stores/user' +import { useConversationStore } from '@main/stores/conversation' defineProps({ userTeams: { type: Array, default: () => [] }, - userViews: { type: Array, default: () => [] } + userViews: { type: Array, default: () => [] }, + sharedViews: { type: Array, default: () => [] } }) const userStore = useUserStore() const conversationStore = useConversationStore() @@ -176,6 +178,7 @@ watch( const sidebarOpen = useStorage('mainSidebarOpen', true) const teamInboxOpen = useStorage('teamInboxOpen', true) const viewInboxOpen = useStorage('viewInboxOpen', true) +const sharedViewInboxOpen = useStorage('sharedViewInboxOpen', true) // Track which view is being hovered for ellipsis menu visibility const hoveredViewId = ref(null) @@ -195,7 +198,7 @@ const viewToDelete = ref(null) @@ -235,7 +237,7 @@ const viewToDelete = ref(null) route.matched.some((record) => record.name && record.name.startsWith('reports')) " > - + @@ -260,13 +262,12 @@ const viewToDelete = ref(null) - - + @@ -569,3 +620,19 @@ const viewToDelete = ref(null) + + diff --git a/frontend/apps/main/src/components/table/SimpleTable.vue b/frontend/apps/main/src/components/table/SimpleTable.vue index 5b55ecf7..5b598c1c 100644 --- a/frontend/apps/main/src/components/table/SimpleTable.vue +++ b/frontend/apps/main/src/components/table/SimpleTable.vue @@ -70,7 +70,6 @@ + \ No newline at end of file diff --git a/frontend/apps/main/src/features/admin/notification/NotificationSetting.vue b/frontend/apps/main/src/features/admin/notification/NotificationSetting.vue index b1e4fa54..b5669a98 100644 --- a/frontend/apps/main/src/features/admin/notification/NotificationSetting.vue +++ b/frontend/apps/main/src/features/admin/notification/NotificationSetting.vue @@ -19,19 +19,21 @@ diff --git a/frontend/apps/main/src/features/admin/oidc/dataTableColumns.js b/frontend/apps/main/src/features/admin/oidc/dataTableColumns.js index f0436796..ccd4fd93 100644 --- a/frontend/apps/main/src/features/admin/oidc/dataTableColumns.js +++ b/frontend/apps/main/src/features/admin/oidc/dataTableColumns.js @@ -29,6 +29,15 @@ export const createColumns = (t) => [ return h('div', { class: 'text-center' }, enabled ? t('globals.messages.yes') : t('globals.messages.no')) } }, + { + accessorKey: 'created_at', + header: function () { + return h('div', { class: 'text-center' }, t('globals.terms.createdAt')) + }, + cell: function ({ row }) { + return h('div', { class: 'text-center' }, format(row.getValue('created_at'), 'PPpp')) + } + }, { accessorKey: 'updated_at', header: function () { @@ -41,6 +50,7 @@ export const createColumns = (t) => [ { id: 'actions', enableHiding: false, + enableSorting: false, cell: ({ row }) => { const role = row.original return h( diff --git a/frontend/apps/main/src/features/admin/oidc/formSchema.js b/frontend/apps/main/src/features/admin/oidc/formSchema.js index 15ddcb54..d7d4abba 100644 --- a/frontend/apps/main/src/features/admin/oidc/formSchema.js +++ b/frontend/apps/main/src/features/admin/oidc/formSchema.js @@ -13,6 +13,7 @@ export const createFormSchema = (t) => z.object({ .url({ message: t('form.error.validUrl'), }), + logo_url: z.string().url({ message: t('form.error.validUrl') }).optional().or(z.literal('')), client_id: z.string({ required_error: t('globals.messages.required'), }), diff --git a/frontend/apps/main/src/features/admin/roles/RoleForm.vue b/frontend/apps/main/src/features/admin/roles/RoleForm.vue index 93ccbf08..a6d5b522 100644 --- a/frontend/apps/main/src/features/admin/roles/RoleForm.vue +++ b/frontend/apps/main/src/features/admin/roles/RoleForm.vue @@ -102,6 +102,7 @@ const submitLabel = computed(() => { return props.submitLabel || t('globals.messages.save') }) +// TODO: Prepare this by fetching all perms from the file, so we don't have to update this manually. const permissions = ref([ { name: t('globals.terms.conversation'), @@ -121,6 +122,10 @@ const permissions = ref([ name: perms.CONVERSATIONS_READ_TEAM_INBOX, label: t('admin.role.conversations.readTeamInbox') }, + { + name: perms.CONVERSATIONS_READ_TEAM_ALL, + label: t('admin.role.conversations.readTeamAll') + }, { name: perms.CONVERSATIONS_UPDATE_USER_ASSIGNEE, label: t('admin.role.conversations.updateUserAssignee') @@ -168,7 +173,8 @@ const permissions = ref([ { name: perms.AI_MANAGE, label: t('admin.role.ai.manage') }, { name: perms.CUSTOM_ATTRIBUTES_MANAGE, label: t('admin.role.customAttributes.manage') }, { name: perms.ACTIVITY_LOGS_MANAGE, label: t('admin.role.activityLog.manage') }, - { name: perms.WEBHOOKS_MANAGE, label: t('admin.role.webhooks.manage') } + { name: perms.WEBHOOKS_MANAGE, label: t('admin.role.webhooks.manage') }, + { name: perms.SHARED_VIEWS_MANAGE, label: t('admin.role.sharedViews.manage') } ] }, { diff --git a/frontend/apps/main/src/features/admin/roles/dataTableColumns.js b/frontend/apps/main/src/features/admin/roles/dataTableColumns.js index f0912d32..c33a24ec 100644 --- a/frontend/apps/main/src/features/admin/roles/dataTableColumns.js +++ b/frontend/apps/main/src/features/admin/roles/dataTableColumns.js @@ -1,4 +1,5 @@ import { h } from 'vue' +import { RouterLink } from 'vue-router' import dropdown from './dataTableDropdown.vue' export const createColumns = (t) => [ @@ -8,7 +9,15 @@ export const createColumns = (t) => [ return h('div', { class: 'text-center' }, t('globals.terms.name')) }, cell: function ({ row }) { - return h('div', { class: 'text-center' }, row.getValue('name')) + return h('div', { class: 'text-center' }, + h(RouterLink, + { + to: { name: 'edit-role', params: { id: row.original.id } }, + class: 'text-primary hover:underline' + }, + () => row.getValue('name') + ) + ) } }, { @@ -23,6 +32,7 @@ export const createColumns = (t) => [ { id: 'actions', enableHiding: false, + enableSorting: false, cell: ({ row }) => { const role = row.original return h( diff --git a/frontend/apps/main/src/features/admin/sla/dataTableColumns.js b/frontend/apps/main/src/features/admin/sla/dataTableColumns.js index f2692d80..9cc5c0e4 100644 --- a/frontend/apps/main/src/features/admin/sla/dataTableColumns.js +++ b/frontend/apps/main/src/features/admin/sla/dataTableColumns.js @@ -1,4 +1,5 @@ import { h } from 'vue' +import { RouterLink } from 'vue-router' import dropdown from './dataTableDropdown.vue' import { format } from 'date-fns' @@ -9,7 +10,15 @@ export const createColumns = (t) => [ return h('div', { class: 'text-center' }, t('globals.terms.name')) }, cell: function ({ row }) { - return h('div', { class: 'text-center' }, row.getValue('name')) + return h('div', { class: 'text-center' }, + h(RouterLink, + { + to: { name: 'edit-sla', params: { id: row.original.id } }, + class: 'text-primary hover:underline' + }, + () => row.getValue('name') + ) + ) } }, { @@ -33,6 +42,7 @@ export const createColumns = (t) => [ { id: 'actions', enableHiding: false, + enableSorting: false, cell: ({ row }) => { const role = row.original return h( diff --git a/frontend/apps/main/src/features/admin/status/dataTableColumns.js b/frontend/apps/main/src/features/admin/status/dataTableColumns.js index 22c6d91c..b6d86ac4 100644 --- a/frontend/apps/main/src/features/admin/status/dataTableColumns.js +++ b/frontend/apps/main/src/features/admin/status/dataTableColumns.js @@ -24,6 +24,7 @@ export const createColumns = (t) => [ { id: 'actions', enableHiding: false, + enableSorting: false, cell: ({ row }) => { const status = row.original return h( diff --git a/frontend/apps/main/src/features/admin/tags/dataTableColumns.js b/frontend/apps/main/src/features/admin/tags/dataTableColumns.js index bd09e8ee..87fafca5 100644 --- a/frontend/apps/main/src/features/admin/tags/dataTableColumns.js +++ b/frontend/apps/main/src/features/admin/tags/dataTableColumns.js @@ -33,6 +33,7 @@ export const createColumns = (t) => [ { id: 'actions', enableHiding: false, + enableSorting: false, cell: ({ row }) => { const tag = row.original return h( diff --git a/frontend/apps/main/src/features/admin/teams/TeamsDataTableColumns.js b/frontend/apps/main/src/features/admin/teams/TeamsDataTableColumns.js index a8dcd83c..e09091a2 100644 --- a/frontend/apps/main/src/features/admin/teams/TeamsDataTableColumns.js +++ b/frontend/apps/main/src/features/admin/teams/TeamsDataTableColumns.js @@ -1,4 +1,5 @@ import { h } from 'vue' +import { RouterLink } from 'vue-router' import TeamDataTableDropdown from '@/features/admin/teams/TeamDataTableDropdown.vue' import { format } from 'date-fns' @@ -9,7 +10,15 @@ export const columns = [ return h('div', { class: 'text-center' }, 'Name') }, cell: function ({ row }) { - return h('div', { class: 'text-center' }, row.getValue('name')) + return h('div', { class: 'text-center' }, + h(RouterLink, + { + to: { name: 'edit-team', params: { id: row.original.id } }, + class: 'text-primary hover:underline' + }, + () => row.getValue('name') + ) + ) } }, { @@ -41,6 +50,7 @@ export const columns = [ { id: 'actions', enableHiding: false, + enableSorting: false, cell: ({ row }) => { const team = row.original return h( diff --git a/frontend/apps/main/src/features/admin/templates/dataTableColumns.js b/frontend/apps/main/src/features/admin/templates/dataTableColumns.js index ffd3dfb4..93b7dcf9 100644 --- a/frontend/apps/main/src/features/admin/templates/dataTableColumns.js +++ b/frontend/apps/main/src/features/admin/templates/dataTableColumns.js @@ -1,4 +1,5 @@ import { h } from 'vue' +import { RouterLink } from 'vue-router' import dropdown from './dataTableDropdown.vue' import { format } from 'date-fns' @@ -9,7 +10,15 @@ export const createOutgoingEmailTableColumns = (t) => [ return h('div', { class: 'text-center' }, t('globals.terms.name')) }, cell: function ({ row }) { - return h('div', { class: 'text-center' }, row.getValue('name')) + return h('div', { class: 'text-center' }, + h(RouterLink, + { + to: { name: 'edit-template', params: { id: row.original.id } }, + class: 'text-primary hover:underline' + }, + () => row.getValue('name') + ) + ) } }, { @@ -39,6 +48,7 @@ export const createOutgoingEmailTableColumns = (t) => [ { id: 'actions', enableHiding: false, + enableSorting: false, cell: ({ row }) => { const template = row.original return h( @@ -60,7 +70,15 @@ export const createEmailNotificationTableColumns = (t) => [ return h('div', { class: 'text-center' }, t('globals.terms.name')) }, cell: function ({ row }) { - return h('div', { class: 'text-center' }, row.getValue('name')) + return h('div', { class: 'text-center' }, + h(RouterLink, + { + to: { name: 'edit-template', params: { id: row.original.id } }, + class: 'text-primary hover:underline' + }, + () => row.getValue('name') + ) + ) } }, @@ -76,6 +94,7 @@ export const createEmailNotificationTableColumns = (t) => [ { id: 'actions', enableHiding: false, + enableSorting: false, cell: ({ row }) => { const template = row.original return h( diff --git a/frontend/apps/main/src/features/admin/webhooks/dataTableColumns.js b/frontend/apps/main/src/features/admin/webhooks/dataTableColumns.js index fcd81e1f..c3813e60 100644 --- a/frontend/apps/main/src/features/admin/webhooks/dataTableColumns.js +++ b/frontend/apps/main/src/features/admin/webhooks/dataTableColumns.js @@ -1,4 +1,5 @@ import { h } from 'vue' +import { RouterLink } from 'vue-router' import dropdown from './dataTableDropdown.vue' import { format } from 'date-fns' import { Badge } from '@shared-ui/components/ui/badge' @@ -10,7 +11,15 @@ export const createColumns = (t) => [ return h('div', { class: 'text-center' }, t('globals.terms.name')) }, cell: function ({ row }) { - return h('div', { class: 'text-center' }, row.getValue('name')) + return h('div', { class: 'text-center' }, + h(RouterLink, + { + to: { name: 'edit-webhook', params: { id: row.original.id } }, + class: 'text-primary hover:underline' + }, + () => row.getValue('name') + ) + ) } }, { @@ -37,6 +46,11 @@ export const createColumns = (t) => [ () => `${events.length} ${t('globals.terms.event', 2).toLowerCase()}` ) ]) + }, + sortingFn: (rowA, rowB) => { + const a = rowA.original.events?.length || 0 + const b = rowB.original.events?.length || 0 + return a - b } }, { @@ -68,6 +82,7 @@ export const createColumns = (t) => [ { id: 'actions', enableHiding: false, + enableSorting: false, cell: ({ row }) => { const webhook = row.original return h( diff --git a/frontend/apps/main/src/features/command/CommandBox.vue b/frontend/apps/main/src/features/command/CommandBox.vue index a373b905..8f68df3d 100644 --- a/frontend/apps/main/src/features/command/CommandBox.vue +++ b/frontend/apps/main/src/features/command/CommandBox.vue @@ -6,30 +6,30 @@ :class="{ 'overflow-hidden': nestedCommand === 'apply-macro' }" > -

{{ $t('command.noCommandAvailable') }}

+

{{ $t('command.noCommandAvailable') }}

- + 1 {{ $t('globals.terms.hour') }} - 3 {{ $t('globals.terms.hour', 2) }} - + + 3 {{ $t('globals.terms.hour', 2) }} + + 6 {{ $t('globals.terms.hour', 2) }} - + 12 {{ $t('globals.terms.hour', 2) }} - + 1 {{ $t('globals.terms.day') }} - + 2 {{ $t('globals.terms.day', 2) }} - + {{ $t('globals.messages.pickDateAndTime') }} @@ -52,11 +52,12 @@ :value="macro.label + '|' + index" :data-index="index" @select="handleApplyMacro(macro)" - class="px-3 py-2 rounded cursor-pointer transition-all duration-200 hover:bg-primary/10 hover:" + @pointerenter="highlightedMacro = macro" + class="px-3 py-3 rounded cursor-pointer transition-all duration-200 hover:bg-primary/10 hover:" > -
- - {{ +
+ + {{ macro.label }}
@@ -65,61 +66,63 @@
-
+
-

+

{{ $t('command.replyPreview') }}

-
-

+

{{ $t('globals.terms.action', 2) }}

- +
@@ -133,7 +136,7 @@ v-if="!replyContent && otherActions.length === 0" class="flex items-center justify-center h-20" > -

+

{{ $t('command.selectAMacro') }}

@@ -153,20 +156,21 @@ {{ $t('globals.messages.apply', { name: t('globals.terms.macro').toLowerCase() }) }} - + {{ $t('globals.terms.snooze') }} - + {{ $t('globals.terms.resolve') }} -
+
{{ $t('command.enterToSelect') }} {{ $t('command.navigateWithArrows') }} {{ $t('command.closeWithEsc') }} @@ -209,9 +213,9 @@ import { ref, watch, onMounted, onUnmounted, computed } from 'vue' import { useMagicKeys } from '@vueuse/core' import { CalendarIcon } from 'lucide-vue-next' -import { useConversationStore } from '../../stores/conversation' -import { useMacroStore } from '../../stores/macro' -import { CONVERSATION_DEFAULT_STATUSES } from '../../constants/conversation' +import { useConversationStore } from '@main/stores/conversation' +import { useMacroStore } from '@main/stores/macro' +import { CONVERSATION_DEFAULT_STATUSES, MACRO_CONTEXT } from '@main/constants/conversation' import { Users, User, Pin, Rocket, Tags, Zap } from 'lucide-vue-next' import { CommandDialog, @@ -230,13 +234,14 @@ import { DialogDescription } from '@shared-ui/components/ui/dialog' import { Popover, PopoverContent, PopoverTrigger } from '@shared-ui/components/ui/popover' -import { EMITTER_EVENTS } from '../../constants/emitterEvents.js' -import { useEmitter } from '../../composables/useEmitter' +import { EMITTER_EVENTS } from '@main/constants/emitterEvents.js' +import { useEmitter } from '@main/composables/useEmitter' import { Button } from '@shared-ui/components/ui/button' import { Calendar } from '@shared-ui/components/ui/calendar' import { Input } from '@shared-ui/components/ui/input' import { Label } from '@shared-ui/components/ui/label' import { useI18n } from 'vue-i18n' +import { Letter } from 'vue-letter' const conversationStore = useConversationStore() const macroStore = useMacroStore() @@ -267,9 +272,9 @@ function handleApplyMacro(macro) { // Create a deep copy. const plainMacro = JSON.parse(JSON.stringify(macro)) if (nestedCommand.value === 'apply-macro-to-new-conversation') { - conversationStore.setMacro(plainMacro, 'new-conversation') + conversationStore.setMacro(plainMacro, MACRO_CONTEXT.NEW_CONVERSATION) } else { - conversationStore.setMacro(plainMacro, 'reply') + conversationStore.setMacro(plainMacro, MACRO_CONTEXT.REPLY) } toggleOpen() } diff --git a/frontend/apps/main/src/features/contact/ContactNotes.vue b/frontend/apps/main/src/features/contact/ContactNotes.vue index 778e6164..5492210e 100644 --- a/frontend/apps/main/src/features/contact/ContactNotes.vue +++ b/frontend/apps/main/src/features/contact/ContactNotes.vue @@ -99,7 +99,11 @@ -

+
@@ -150,12 +154,13 @@ import { } from 'lucide-vue-next' import Editor from '@main/components/editor/TextEditor.vue' import { useI18n } from 'vue-i18n' -import { useEmitter } from '../../composables/useEmitter' -import { EMITTER_EVENTS } from '../../constants/emitterEvents.js' -import { handleHTTPError } from '../../utils/http' +import { useEmitter } from '@main/composables/useEmitter' +import { EMITTER_EVENTS } from '@main/constants/emitterEvents.js' +import { handleHTTPError } from '@main/utils/http' import { getInitials } from '@shared-ui/utils/string' -import { useUserStore } from '../../stores/user' -import api from '../../api' +import { useUserStore } from '@main/stores/user' +import { Letter } from 'vue-letter' +import api from '@main/api' const props = defineProps({ contactId: Number }) const { t } = useI18n() diff --git a/frontend/apps/main/src/features/contact/ContactsList.vue b/frontend/apps/main/src/features/contact/ContactsList.vue index 5dd7d06b..29680f67 100644 --- a/frontend/apps/main/src/features/contact/ContactsList.vue +++ b/frontend/apps/main/src/features/contact/ContactsList.vue @@ -190,12 +190,12 @@ import { Input } from '@shared-ui/components/ui/input' import { Button } from '@shared-ui/components/ui/button' import { ArrowDownWideNarrow } from 'lucide-vue-next' import { Popover, PopoverContent, PopoverTrigger } from '@shared-ui/components/ui/popover' -import { debounce } from '../../utils/debounce' -import { EMITTER_EVENTS } from '../../constants/emitterEvents.js' -import { useEmitter } from '../../composables/useEmitter' -import { handleHTTPError } from '../../utils/http' -import { getVisiblePages } from '../../utils/pagination' -import api from '../../api' +import { useDebounceFn } from '@vueuse/core' +import { EMITTER_EVENTS } from '@main/constants/emitterEvents.js' +import { useEmitter } from '@main/composables/useEmitter' +import { handleHTTPError } from '@main/utils/http' +import { getVisiblePages } from '@main/utils/pagination' +import api from '@main/api' const contacts = ref([]) const loading = ref(false) @@ -211,7 +211,7 @@ const emitter = useEmitter() // Google-style pagination const visiblePages = computed(() => getVisiblePages(page.value, totalPages.value)) -const fetchContactsDebounced = debounce(() => { +const fetchContactsDebounced = useDebounceFn(() => { fetchContacts() }, 300) diff --git a/frontend/apps/main/src/features/conversation/CreateConversation.vue b/frontend/apps/main/src/features/conversation/CreateConversation.vue index 6943f637..52e34809 100644 --- a/frontend/apps/main/src/features/conversation/CreateConversation.vue +++ b/frontend/apps/main/src/features/conversation/CreateConversation.vue @@ -185,10 +185,10 @@ @@ -244,12 +244,13 @@ import AttachmentsPreview from '@/features/conversation/message/attachment/Attac import { useConversationStore } from '../../stores/conversation' import MacroActionsPreview from '@/features/conversation/MacroActionsPreview.vue' import ReplyBoxMenuBar from '@/features/conversation/ReplyBoxMenuBar.vue' -import { EMITTER_EVENTS } from '../../constants/emitterEvents.js' -import { useEmitter } from '../../composables/useEmitter' -import { handleHTTPError } from '../../utils/http' -import { useInboxStore } from '../../stores/inbox' -import { useUsersStore } from '../../stores/users' -import { useTeamStore } from '../../stores/team' +import { EMITTER_EVENTS } from '@main/constants/emitterEvents.js' +import { MACRO_CONTEXT } from '@main/constants/conversation' +import { useEmitter } from '@main/composables/useEmitter' +import { handleHTTPError } from '@main/utils/http' +import { useInboxStore } from '@main/stores/inbox' +import { useUsersStore } from '@main/stores/users' +import { useTeamStore } from '@main/stores/team' import { Select, SelectContent, @@ -328,7 +329,7 @@ const formSchema = z.object({ onUnmounted(() => { clearTimeout(timeoutId) clearMediaFiles() - conversationStore.resetMacro('new-conversation') + conversationStore.resetMacro(MACRO_CONTEXT.NEW_CONVERSATION) emitter.emit(EMITTER_EVENTS.SET_NESTED_COMMAND, { command: null, open: false @@ -406,7 +407,7 @@ const createConversation = form.handleSubmit(async (values) => { const conversationUUID = conversation.data.data.uuid // Get macro from context, and set if any actions are available. - const macro = conversationStore.getMacro('new-conversation') + const macro = conversationStore.getMacro(MACRO_CONTEXT.NEW_CONVERSATION) if (conversationUUID !== '' && macro?.id && macro?.actions?.length > 0) { try { await api.applyMacro(conversationUUID, macro.id, macro.actions) @@ -433,9 +434,9 @@ const createConversation = form.handleSubmit(async (values) => { * Watches for changes in the macro id and update message content. */ watch( - () => conversationStore.getMacro('new-conversation').id, + () => conversationStore.getMacro(MACRO_CONTEXT.NEW_CONVERSATION).id, () => { - form.setFieldValue('content', conversationStore.getMacro('new-conversation').message_content) + form.setFieldValue('content', conversationStore.getMacro(MACRO_CONTEXT.NEW_CONVERSATION).message_content) }, { deep: true } ) diff --git a/frontend/apps/main/src/features/conversation/ReplyBox.vue b/frontend/apps/main/src/features/conversation/ReplyBox.vue index ac646f4d..22920732 100644 --- a/frontend/apps/main/src/features/conversation/ReplyBox.vue +++ b/frontend/apps/main/src/features/conversation/ReplyBox.vue @@ -42,7 +42,7 @@ @@ -51,6 +51,7 @@ :isFullscreen="true" :aiPrompts="aiPrompts" :isSending="isSending" + :isDraftLoading="isDraftLoading" :uploadingFiles="uploadingFiles" :uploadedFiles="mediaFiles" v-model:htmlContent="htmlContent" @@ -61,6 +62,7 @@ v-model:emailErrors="emailErrors" v-model:messageType="messageType" v-model:showBcc="showBcc" + v-model:mentions="mentions" @toggleFullscreen="isEditorFullscreen = !isEditorFullscreen" @send="processSend" @fileUpload="handleFileUpload" @@ -74,13 +76,15 @@
diff --git a/frontend/apps/main/src/features/conversation/ReplyBoxContent.vue b/frontend/apps/main/src/features/conversation/ReplyBoxContent.vue index f4775544..906f103b 100644 --- a/frontend/apps/main/src/features/conversation/ReplyBoxContent.vue +++ b/frontend/apps/main/src/features/conversation/ReplyBoxContent.vue @@ -86,6 +86,7 @@
@@ -130,20 +135,23 @@ diff --git a/frontend/apps/main/src/features/conversation/list/ConversationList.vue b/frontend/apps/main/src/features/conversation/list/ConversationList.vue index d39bdbfe..690c8cac 100644 --- a/frontend/apps/main/src/features/conversation/list/ConversationList.vue +++ b/frontend/apps/main/src/features/conversation/list/ConversationList.vue @@ -29,7 +29,11 @@ -
+
+ +
@@ -100,6 +104,7 @@ v-if="!conversationStore.conversations.errorMessage" key="list" class="divide-y divide-gray-200 dark:divide-gray-700" + :class="{ 'border-b dark:border-gray-700': hasConversations }" > -
-
- - - - - {{ conversation.contact.first_name.substring(0, 2).toUpperCase() }} - - - - -
- -
-

- {{ contactFullName }} -

- - {{ relativeLastMessageTime }} - -
- - -

- - - {{ conversation.inbox_name }} -

- - -
-
- + + +
+ + + - {{ trimmedLastMessage }} -
-
- {{ conversation.unread_message_count }} + + {{ conversation.contact.first_name.substring(0, 2).toUpperCase() }} + + + + +
+ +
+
+

+ {{ contactFullName }} +

+ +
+ + {{ relativeLastMessageTime }} + +
+ + +

+ + {{ conversation.inbox_name }} +

+ + +
+
+ + {{ trimmedLastMessage }} +
+
+ {{ conversation.unread_message_count }} +
+
+ + +
+
+ +
+
+ +
+
+ +
+
- - -
-
- -
-
- -
-
- -
-
-
-
-
+ + + + + + {{ $t('globals.messages.markAsUnread') }} + + + diff --git a/frontend/apps/main/src/features/conversation/message/AgentMessageBubble.vue b/frontend/apps/main/src/features/conversation/message/AgentMessageBubble.vue deleted file mode 100644 index e3f9641b..00000000 --- a/frontend/apps/main/src/features/conversation/message/AgentMessageBubble.vue +++ /dev/null @@ -1,161 +0,0 @@ - - - - - diff --git a/frontend/apps/main/src/features/conversation/message/ContactMessageBubble.vue b/frontend/apps/main/src/features/conversation/message/ContactMessageBubble.vue deleted file mode 100644 index 51fecb81..00000000 --- a/frontend/apps/main/src/features/conversation/message/ContactMessageBubble.vue +++ /dev/null @@ -1,145 +0,0 @@ - - - diff --git a/frontend/apps/main/src/features/conversation/message/MessageBubble.vue b/frontend/apps/main/src/features/conversation/message/MessageBubble.vue new file mode 100644 index 00000000..04bd1eef --- /dev/null +++ b/frontend/apps/main/src/features/conversation/message/MessageBubble.vue @@ -0,0 +1,230 @@ + + + \ No newline at end of file diff --git a/frontend/apps/main/src/features/conversation/message/MessageList.vue b/frontend/apps/main/src/features/conversation/message/MessageList.vue index c029a9cb..2d60060b 100644 --- a/frontend/apps/main/src/features/conversation/message/MessageList.vue +++ b/frontend/apps/main/src/features/conversation/message/MessageList.vue @@ -26,17 +26,17 @@
-
- - +
+
- +
@@ -62,19 +62,21 @@ + + diff --git a/frontend/apps/main/src/features/conversation/message/attachment/AttachmentsPreview.vue b/frontend/apps/main/src/features/conversation/message/attachment/AttachmentsPreview.vue index 9b9e1d26..7242212d 100644 --- a/frontend/apps/main/src/features/conversation/message/attachment/AttachmentsPreview.vue +++ b/frontend/apps/main/src/features/conversation/message/attachment/AttachmentsPreview.vue @@ -73,6 +73,7 @@ const allAttachments = computed(() => [ ]) const getAttachmentName = (name) => { + if (!name) return '' return name.length > 20 ? name.substring(0, 17) + '...' : name } diff --git a/frontend/apps/main/src/features/conversation/sidebar/ConversationSideBar.vue b/frontend/apps/main/src/features/conversation/sidebar/ConversationSideBar.vue index 8486bd27..85288d00 100644 --- a/frontend/apps/main/src/features/conversation/sidebar/ConversationSideBar.vue +++ b/frontend/apps/main/src/features/conversation/sidebar/ConversationSideBar.vue @@ -2,13 +2,13 @@
- - + + {{ $t('globals.terms.action', 2) }} - + - - + + {{ $t('conversation.sidebar.information') }} - + @@ -67,13 +67,13 @@ - + {{ $t('conversation.sidebar.contactAttributes') }} - + - - + + {{ $t('conversation.sidebar.previousConvo') }} - + @@ -247,3 +247,21 @@ const updateContactCustomAttributes = async (attributes) => { } } + + diff --git a/frontend/apps/main/src/features/conversation/sidebar/ConversationSideBarWrapper.vue b/frontend/apps/main/src/features/conversation/sidebar/ConversationSideBarWrapper.vue deleted file mode 100644 index 13a95f5d..00000000 --- a/frontend/apps/main/src/features/conversation/sidebar/ConversationSideBarWrapper.vue +++ /dev/null @@ -1,56 +0,0 @@ - - - diff --git a/frontend/apps/main/src/features/conversation/sidebar/PreviousConversations.vue b/frontend/apps/main/src/features/conversation/sidebar/PreviousConversations.vue index 15ea5dcd..d1a591e1 100644 --- a/frontend/apps/main/src/features/conversation/sidebar/PreviousConversations.vue +++ b/frontend/apps/main/src/features/conversation/sidebar/PreviousConversations.vue @@ -21,18 +21,25 @@ }" class="block p-2 rounded hover:bg-muted" > -
-
- - {{ conversation.contact.first_name }} {{ conversation.contact.last_name }} - - +
+
+ + + + {{ conversation.subject }} + + + + {{ conversation.subject }} + + + {{ conversation.last_message }}
-
+
{{ getRelativeTime(new Date(conversation.created_at)) }} diff --git a/frontend/apps/main/src/features/reports/OverviewCard.vue b/frontend/apps/main/src/features/reports/OverviewCard.vue index 7c448d92..d5bc454c 100644 --- a/frontend/apps/main/src/features/reports/OverviewCard.vue +++ b/frontend/apps/main/src/features/reports/OverviewCard.vue @@ -1,16 +1,17 @@