diff --git a/cmd/chat.go b/cmd/chat.go
index 113a0dd0..91a0eb79 100644
--- a/cmd/chat.go
+++ b/cmd/chat.go
@@ -283,7 +283,7 @@ func handleChatInit(r *fastglue.Request) error {
}
// Process post-message hooks for the new conversation and initial message.
- if err := app.conversation.ProcessIncomingMessageHooks(conversationUUID, true); err != nil {
+ if err := app.conversation.ProcessIncomingMessageHooks(message, true); err != nil {
app.lo.Error("error processing incoming message hooks for initial message", "conversation_uuid", conversationUUID, "error", err)
}
diff --git a/cmd/handlers.go b/cmd/handlers.go
index 4a31cf9a..bd2fdc1e 100644
--- a/cmd/handlers.go
+++ b/cmd/handlers.go
@@ -353,6 +353,10 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.PUT("/api/v1/notifications/read-all", auth(handleMarkAllNotificationsAsRead))
g.DELETE("/api/v1/notifications/{id}", auth(handleDeleteNotification))
g.DELETE("/api/v1/notifications", auth(handleDeleteAllNotifications))
+ g.GET("/api/v1/notifications/preferences", auth(handleGetNotificationPreferences))
+ g.PUT("/api/v1/notifications/preferences", auth(handleUpdateNotificationPreferences))
+ g.POST("/api/v1/notifications/push-subscriptions", auth(handleCreatePushSubscription))
+ g.DELETE("/api/v1/notifications/push-subscriptions", auth(handleDeletePushSubscription))
// WebSocket.
g.GET("/ws", auth(func(r *fastglue.Request) error {
@@ -399,6 +403,8 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.GET("/assets/{all:*}", serveFrontendStaticFiles)
g.GET("/widget/assets/{all:*}", serveWidgetStaticFiles)
g.GET("/images/{all:*}", serveFrontendStaticFiles)
+ g.GET("/manifest.webmanifest", serveManifest)
+ g.GET("/sw.js", serveServiceWorker)
g.GET("/static/public/{all:*}", serveStaticFiles)
// Public pages.
@@ -518,6 +524,27 @@ func serveFrontendStaticFiles(r *fastglue.Request) error {
return nil
}
+func serveManifest(r *fastglue.Request) error {
+ return serveMainFrontendFile(r, "manifest.webmanifest", "application/manifest+json", "no-cache")
+}
+
+func serveServiceWorker(r *fastglue.Request) error {
+ r.RequestCtx.Response.Header.Set("Service-Worker-Allowed", "/")
+ return serveMainFrontendFile(r, "sw.js", "application/javascript", "no-cache")
+}
+
+func serveMainFrontendFile(r *fastglue.Request, name, contentType, cacheControl string) error {
+ app := r.Context.(*App)
+ file, err := app.fs.Get(filepath.Join(frontendDir, name))
+ if err != nil {
+ return r.SendErrorEnvelope(http.StatusNotFound, app.i18n.T("validation.notFoundFile"), nil, envelope.NotFoundError)
+ }
+ r.RequestCtx.Response.Header.Set("Content-Type", contentType)
+ r.RequestCtx.Response.Header.Set("Cache-Control", cacheControl)
+ r.RequestCtx.SetBody(file.ReadBytes())
+ return nil
+}
+
// serveWidgetStaticFiles serves widget static assets from the embedded filesystem.
func serveWidgetStaticFiles(r *fastglue.Request) error {
app := r.Context.(*App)
diff --git a/cmd/init.go b/cmd/init.go
index d3f59932..e5f5886c 100644
--- a/cmd/init.go
+++ b/cmd/init.go
@@ -39,6 +39,7 @@ import (
fs "github.com/abhinavxd/libredesk/internal/media/stores/localfs"
"github.com/abhinavxd/libredesk/internal/media/stores/s3"
notifier "github.com/abhinavxd/libredesk/internal/notification"
+ notificationchannels "github.com/abhinavxd/libredesk/internal/notification/channels"
emailnotifier "github.com/abhinavxd/libredesk/internal/notification/providers/email"
"github.com/abhinavxd/libredesk/internal/oidc"
"github.com/abhinavxd/libredesk/internal/ratelimit"
@@ -1166,6 +1167,19 @@ func initUserNotification(db *sqlx.DB, i18n *i18n.I18n) *notifier.UserNotificati
return m
}
+// initNotificationPreference inits the notification preference manager.
+func initNotificationPreference(db *sqlx.DB, i18n *i18n.I18n) *notifier.PreferenceManager {
+ m, err := notifier.NewPreferenceManager(notifier.PreferenceManagerOpts{
+ DB: db,
+ Lo: initLogger("notification-preference"),
+ I18n: i18n,
+ })
+ if err != nil {
+ log.Fatalf("error initializing notification preference manager: %v", err)
+ }
+ return m
+}
+
// initImporter inits the importer manager.
func initImporter(i18n *i18n.I18n) *importer.Importer {
return importer.New(importer.Opts{
@@ -1174,14 +1188,46 @@ func initImporter(i18n *i18n.I18n) *importer.Importer {
})
}
+func initNotificationEmailQueue(db *sqlx.DB, outbound *notifier.Service) *notifier.EmailQueue {
+ q, err := notifier.NewEmailQueue(notifier.EmailQueueOpts{
+ DB: db,
+ Outbound: outbound,
+ Lo: initLogger("notification-email-queue"),
+ })
+ if err != nil {
+ log.Fatalf("error initializing notification email queue: %v", err)
+ }
+ return q
+}
+
+func initPushNotification(db *sqlx.DB, settings *setting.Manager, i18n *i18n.I18n) *notifier.PushManager {
+ m, err := notifier.NewPushManager(notifier.PushManagerOpts{
+ DB: db,
+ Settings: settings,
+ Lo: initLogger("push-notification"),
+ I18n: i18n,
+ RootURL: ko.String("app.root_url"),
+ Concurrency: ko.MustInt("notification.concurrency"),
+ QueueSize: ko.MustInt("notification.queue_size"),
+ })
+ if err != nil {
+ log.Fatalf("error initializing push notification manager: %v", err)
+ }
+ return m
+}
+
// initNotifDispatcher initializes the notification dispatcher.
-func initNotifDispatcher(userNotification *notifier.UserNotificationManager, outbound *notifier.Service, wsHub *ws.Hub, emailEnabled bool) *notifier.Dispatcher {
+func initNotifDispatcher(userNotification *notifier.UserNotificationManager, prefs *notifier.PreferenceManager, push *notifier.PushManager, emailQueue *notifier.EmailQueue, wsHub *ws.Hub, emailEnabled bool) *notifier.Dispatcher {
+ providers := []notificationchannels.Provider{
+ notificationchannels.NewInApp(userNotification, wsHub, initLogger("notification-in-app")),
+ }
+ if emailEnabled {
+ providers = append(providers, notificationchannels.NewEmail(emailQueue))
+ }
+ providers = append(providers, notificationchannels.NewPush(push))
return notifier.NewDispatcher(notifier.DispatcherOpts{
- InApp: userNotification,
- Outbound: outbound,
- WSHub: wsHub,
- EmailEnabled: emailEnabled,
- Lo: initLogger("notification-dispatcher"),
+ Pipeline: notificationchannels.NewPipeline(providers...),
+ Prefs: prefs,
})
}
diff --git a/cmd/main.go b/cmd/main.go
index 04778478..687c3e55 100644
--- a/cmd/main.go
+++ b/cmd/main.go
@@ -126,6 +126,8 @@ type App struct {
activityLog *activitylog.Manager
notifier *notifier.Service
userNotification *notifier.UserNotificationManager
+ notificationPref *notifier.PreferenceManager
+ pushNotification *notifier.PushManager
customAttribute *customAttribute.Manager
report *report.Manager
webhook *webhook.Manager
@@ -249,7 +251,10 @@ func main() {
wsHub = initWS(user)
notifier = initNotifier()
userNotification = initUserNotification(db, i18n)
- notifDispatcher = initNotifDispatcher(userNotification, notifier, wsHub, ko.Bool("notification.email.enabled"))
+ notificationPreference = initNotificationPreference(db, i18n)
+ pushNotification = initPushNotification(db, settings, i18n)
+ notificationEmailQueue = initNotificationEmailQueue(db, notifier)
+ notifDispatcher = initNotifDispatcher(userNotification, notificationPreference, pushNotification, notificationEmailQueue, wsHub, ko.Bool("notification.email.enabled"))
automation = initAutomationEngine(db, i18n)
ai = initAI(ctx, db, i18n, ssrfControl)
sla = initSLA(db, team, settings, businessHours, template, user, i18n, notifDispatcher)
@@ -285,6 +290,10 @@ func main() {
go conversation.RunDraftCleaner(ctx, draftRetentionDuration)
go userNotification.RunNotificationCleaner(ctx)
go helpCenter.RunSearchLogCleaner(ctx)
+ if ko.Bool("notification.email.enabled") {
+ go notificationEmailQueue.Run(ctx)
+ }
+ go pushNotification.Run(ctx)
go aiAgent.Run(ctx, cmp.Or(ko.Int("ai_agent.worker_count"), 10))
go ai.Run(ctx)
@@ -329,6 +338,8 @@ func main() {
redis: rdb,
fc: initFastCache(rdb),
userNotification: userNotification,
+ notificationPref: notificationPreference,
+ pushNotification: pushNotification,
wsHub: wsHub,
}
app.consts.Store(constants)
diff --git a/cmd/pwa_test.go b/cmd/pwa_test.go
new file mode 100644
index 00000000..65c1c7f9
--- /dev/null
+++ b/cmd/pwa_test.go
@@ -0,0 +1,51 @@
+package main
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/knadh/stuffbin"
+ "github.com/valyala/fasthttp"
+ "github.com/zerodha/fastglue"
+)
+
+func TestServePWAFiles(t *testing.T) {
+ fs, err := stuffbin.NewLocalFS("/",
+ "../frontend/public/manifest.webmanifest:frontend/dist/main/manifest.webmanifest",
+ "../frontend/public/sw.js:frontend/dist/main/sw.js",
+ )
+ if err != nil {
+ t.Fatalf("creating test filesystem: %v", err)
+ }
+
+ tests := []struct {
+ name string
+ handler fastglue.FastRequestHandler
+ contentType string
+ wantServiceWorkerHdr bool
+ }{
+ {"manifest", serveManifest, "application/manifest+json", false},
+ {"service worker", serveServiceWorker, "application/javascript", true},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ ctx := &fasthttp.RequestCtx{}
+ req := &fastglue.Request{RequestCtx: ctx, Context: &App{fs: fs}}
+ if err := tc.handler(req); err != nil {
+ t.Fatalf("serving file: %v", err)
+ }
+ if got := string(ctx.Response.Header.ContentType()); got != tc.contentType {
+ t.Errorf("content type = %q, want %q", got, tc.contentType)
+ }
+ if got := string(ctx.Response.Header.Peek("Cache-Control")); got != "no-cache" {
+ t.Errorf("cache control = %q, want no-cache", got)
+ }
+ if tc.wantServiceWorkerHdr && string(ctx.Response.Header.Peek("Service-Worker-Allowed")) != "/" {
+ t.Error("service worker scope header is missing")
+ }
+ if strings.TrimSpace(string(ctx.Response.Body())) == "" {
+ t.Error("response body is empty")
+ }
+ })
+ }
+}
diff --git a/cmd/settings.go b/cmd/settings.go
index 6cb48c8f..4f09c9ab 100644
--- a/cmd/settings.go
+++ b/cmd/settings.go
@@ -6,6 +6,7 @@ import (
"strings"
"github.com/abhinavxd/libredesk/internal/envelope"
+ "github.com/abhinavxd/libredesk/internal/httputil"
"github.com/abhinavxd/libredesk/internal/setting/models"
"github.com/abhinavxd/libredesk/internal/stringutil"
"github.com/valyala/fasthttp"
@@ -57,6 +58,9 @@ func handleUpdateGeneralSettings(r *fastglue.Request) error {
}
// Trim whitespace and trailing slash from root URL.
req.RootURL = strings.TrimRight(strings.TrimSpace(req.RootURL), "/")
+ if !httputil.IsValidHTTPURL(req.RootURL) {
+ return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("admin.general.rootURL.valid"), nil, envelope.InputError)
+ }
// Get current language before update.
app.Lock()
diff --git a/cmd/user_notifications.go b/cmd/user_notifications.go
index caadb242..3f39d96b 100644
--- a/cmd/user_notifications.go
+++ b/cmd/user_notifications.go
@@ -5,6 +5,8 @@ import (
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
"github.com/abhinavxd/libredesk/internal/envelope"
+ notifier "github.com/abhinavxd/libredesk/internal/notification"
+ nmodels "github.com/abhinavxd/libredesk/internal/notification/models"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
)
@@ -97,3 +99,72 @@ func handleDeleteAllNotifications(r *fastglue.Request) error {
}
return r.SendEnvelope(true)
}
+
+func handleGetNotificationPreferences(r *fastglue.Request) error {
+ var (
+ app = r.Context.(*App)
+ auser = r.RequestCtx.UserValue("user").(amodels.User)
+ )
+ prefs, err := app.notificationPref.GetMatrix(auser.ID)
+ if err != nil {
+ return sendErrorEnvelope(r, err)
+ }
+ pushEndpoints, err := app.pushNotification.Endpoints(auser.ID)
+ if err != nil {
+ return sendErrorEnvelope(r, err)
+ }
+ return r.SendEnvelope(map[string]any{
+ "preferences": prefs,
+ "email_enabled": ko.Bool("notification.email.enabled"),
+ "vapid_public_key": app.pushNotification.PublicKey(),
+ "push_endpoints": pushEndpoints,
+ })
+}
+
+func handleUpdateNotificationPreferences(r *fastglue.Request) error {
+ var (
+ app = r.Context.(*App)
+ auser = r.RequestCtx.UserValue("user").(amodels.User)
+ prefs []nmodels.NotificationPreference
+ )
+ if err := r.Decode(&prefs, "json"); err != nil {
+ return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
+ app.i18n.T("errors.parsingRequest"), nil, envelope.InputError)
+ }
+ if err := app.notificationPref.Update(auser.ID, prefs); err != nil {
+ return sendErrorEnvelope(r, err)
+ }
+ return r.SendEnvelope(true)
+}
+
+func handleCreatePushSubscription(r *fastglue.Request) error {
+ var (
+ app = r.Context.(*App)
+ auser = r.RequestCtx.UserValue("user").(amodels.User)
+ subscription notifier.PushSubscriptionInput
+ )
+ if err := r.Decode(&subscription, "json"); err != nil {
+ return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("errors.parsingRequest"), nil, envelope.InputError)
+ }
+ if err := app.pushNotification.Upsert(auser.ID, subscription); err != nil {
+ return sendErrorEnvelope(r, err)
+ }
+ return r.SendEnvelope(true)
+}
+
+func handleDeletePushSubscription(r *fastglue.Request) error {
+ var (
+ app = r.Context.(*App)
+ auser = r.RequestCtx.UserValue("user").(amodels.User)
+ request struct {
+ Endpoint string `json:"endpoint"`
+ }
+ )
+ if err := r.Decode(&request, "json"); err != nil {
+ return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("errors.parsingRequest"), nil, envelope.InputError)
+ }
+ if err := app.pushNotification.Delete(auser.ID, request.Endpoint); err != nil {
+ return sendErrorEnvelope(r, err)
+ }
+ return r.SendEnvelope(true)
+}
diff --git a/frontend/apps/main/index.html b/frontend/apps/main/index.html
index 781f17db..63340480 100644
--- a/frontend/apps/main/index.html
+++ b/frontend/apps/main/index.html
@@ -5,6 +5,9 @@
+
+
+
@@ -14,4 +17,4 @@