diff --git a/cli/notifications.go b/cli/notifications.go index 1769ef3aa154a..a0f155053742b 100644 --- a/cli/notifications.go +++ b/cli/notifications.go @@ -16,7 +16,7 @@ func (r *RootCmd) notifications() *serpent.Command { Short: "Manage Coder notifications", Long: "Administrators can use these commands to change notification settings.\n" + FormatExamples( Example{ - Description: "Pause Coder notifications. Administrators can temporarily stop notifiers from dispatching messages in case of the target outage (for example: unavailable SMTP server or Webhook not responding).", + Description: "Pause Coder notifications. Administrators can temporarily stop notifiers from dispatching messages in case of the target outage (for example: unavailable SMTP server or Webhook not responding)", Command: "coder notifications pause", }, Example{ @@ -24,9 +24,13 @@ func (r *RootCmd) notifications() *serpent.Command { Command: "coder notifications resume", }, Example{ - Description: "Send a test notification. Administrators can use this to verify the notification target settings.", + Description: "Send a test notification. Administrators can use this to verify the notification target settings", Command: "coder notifications test", }, + Example{ + Description: "Send a custom notification to the requesting user. Sending notifications targeting other users or groups is currently not supported", + Command: "coder notifications custom \"Custom Title\" \"Custom Message\"", + }, ), Aliases: []string{"notification"}, Handler: func(inv *serpent.Invocation) error { @@ -36,6 +40,7 @@ func (r *RootCmd) notifications() *serpent.Command { r.pauseNotifications(), r.resumeNotifications(), r.testNotifications(), + r.customNotifications(), }, } return cmd @@ -109,3 +114,30 @@ func (r *RootCmd) testNotifications() *serpent.Command { } return cmd } + +func (r *RootCmd) customNotifications() *serpent.Command { + client := new(codersdk.Client) + cmd := &serpent.Command{ + Use: "custom <message>", + Short: "Send a custom notification", + Middleware: serpent.Chain( + serpent.RequireNArgs(2), + r.InitClient(client), + ), + Handler: func(inv *serpent.Invocation) error { + err := client.PostCustomNotification(inv.Context(), codersdk.CustomNotificationRequest{ + Content: &codersdk.CustomNotificationContent{ + Title: inv.Args[0], + Message: inv.Args[1], + }, + }) + if err != nil { + return xerrors.Errorf("unable to post custom notification: %w", err) + } + + _, _ = fmt.Fprintln(inv.Stderr, "A custom notification has been sent.") + return nil + }, + } + return cmd +} diff --git a/cli/notifications_test.go b/cli/notifications_test.go index 0e8ece285b450..f5618d33c8aba 100644 --- a/cli/notifications_test.go +++ b/cli/notifications_test.go @@ -12,6 +12,8 @@ import ( "github.com/coder/coder/v2/cli/clitest" "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/notifications" "github.com/coder/coder/v2/coderd/notifications/notificationstest" "github.com/coder/coder/v2/codersdk" @@ -166,3 +168,102 @@ func TestNotificationsTest(t *testing.T) { require.Len(t, sent, 0) }) } + +func TestCustomNotifications(t *testing.T) { + t.Parallel() + + t.Run("BadRequest", func(t *testing.T) { + t.Parallel() + + notifyEnq := ¬ificationstest.FakeEnqueuer{} + + ownerClient := coderdtest.New(t, &coderdtest.Options{ + DeploymentValues: coderdtest.DeploymentValues(t), + NotificationsEnqueuer: notifyEnq, + }) + + // Given: A member user + ownerUser := coderdtest.CreateFirstUser(t, ownerClient) + memberClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, ownerUser.OrganizationID) + + // When: The member user attempts to send a custom notification with empty title and message + inv, root := clitest.New(t, "notifications", "custom", "", "") + clitest.SetupConfig(t, memberClient, root) + + // Then: an error is expected with no notifications sent + err := inv.Run() + var sdkError *codersdk.Error + require.Error(t, err) + require.ErrorAsf(t, err, &sdkError, "error should be of type *codersdk.Error") + require.Equal(t, http.StatusBadRequest, sdkError.StatusCode()) + require.Equal(t, "Invalid request body", sdkError.Message) + + sent := notifyEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateTestNotification)) + require.Len(t, sent, 0) + }) + + t.Run("SystemUserNotAllowed", func(t *testing.T) { + t.Parallel() + + notifyEnq := ¬ificationstest.FakeEnqueuer{} + + ownerClient, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{ + DeploymentValues: coderdtest.DeploymentValues(t), + NotificationsEnqueuer: notifyEnq, + }) + + // Given: A system user (prebuilds system user) + _, token := dbgen.APIKey(t, db, database.APIKey{ + UserID: database.PrebuildsSystemUserID, + LoginType: database.LoginTypeNone, + }) + systemUserClient := codersdk.New(ownerClient.URL) + systemUserClient.SetSessionToken(token) + + // When: The system user attempts to send a custom notification + inv, root := clitest.New(t, "notifications", "custom", "Custom Title", "Custom Message") + clitest.SetupConfig(t, systemUserClient, root) + + // Then: an error is expected with no notifications sent + err := inv.Run() + var sdkError *codersdk.Error + require.Error(t, err) + require.ErrorAsf(t, err, &sdkError, "error should be of type *codersdk.Error") + require.Equal(t, http.StatusForbidden, sdkError.StatusCode()) + require.Equal(t, "Forbidden", sdkError.Message) + + sent := notifyEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateTestNotification)) + require.Len(t, sent, 0) + }) + + t.Run("Success", func(t *testing.T) { + t.Parallel() + + notifyEnq := ¬ificationstest.FakeEnqueuer{} + + ownerClient := coderdtest.New(t, &coderdtest.Options{ + DeploymentValues: coderdtest.DeploymentValues(t), + NotificationsEnqueuer: notifyEnq, + }) + + // Given: A member user + ownerUser := coderdtest.CreateFirstUser(t, ownerClient) + memberClient, memberUser := coderdtest.CreateAnotherUser(t, ownerClient, ownerUser.OrganizationID) + + // When: The member user attempts to send a custom notification + inv, root := clitest.New(t, "notifications", "custom", "Custom Title", "Custom Message") + clitest.SetupConfig(t, memberClient, root) + + // Then: we expect a custom notification to be sent to the member user + err := inv.Run() + require.NoError(t, err) + + sent := notifyEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateCustomNotification)) + require.Len(t, sent, 1) + require.Equal(t, memberUser.ID, sent[0].UserID) + require.Len(t, sent[0].Labels, 2) + require.Equal(t, "Custom Title", sent[0].Labels["custom_title"]) + require.Equal(t, "Custom Message", sent[0].Labels["custom_message"]) + require.Equal(t, memberUser.ID.String(), sent[0].CreatedBy) + }) +} diff --git a/cli/testdata/coder_notifications_--help.golden b/cli/testdata/coder_notifications_--help.golden index ced45ca0da6e5..5eec2d3bff934 100644 --- a/cli/testdata/coder_notifications_--help.golden +++ b/cli/testdata/coder_notifications_--help.golden @@ -12,7 +12,7 @@ USAGE: from dispatching messages in case of the target outage (for example: unavailable SMTP - server or Webhook not responding).: + server or Webhook not responding): $ coder notifications pause @@ -22,11 +22,17 @@ USAGE: - Send a test notification. Administrators can use this to verify the notification - target settings.: + target settings: $ coder notifications test + + - Send a custom notification to the requesting user. Sending notifications + targeting other users or groups is currently not supported: + + $ coder notifications custom "Custom Title" "Custom Message" SUBCOMMANDS: + custom Send a custom notification pause Pause notifications resume Resume notifications test Send a test notification diff --git a/cli/testdata/coder_notifications_custom_--help.golden b/cli/testdata/coder_notifications_custom_--help.golden new file mode 100644 index 0000000000000..eeedc322715ab --- /dev/null +++ b/cli/testdata/coder_notifications_custom_--help.golden @@ -0,0 +1,9 @@ +coder v0.0.0-devel + +USAGE: + coder notifications custom <title> <message> + + Send a custom notification + +——— +Run `coder --help` for a list of global options. diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 89a70d688bf88..826b3ee035f0b 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -1673,6 +1673,60 @@ const docTemplate = `{ } } }, + "/notifications/custom": { + "post": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Notifications" + ], + "summary": "Send a custom notification", + "operationId": "send-a-custom-notification", + "parameters": [ + { + "description": "Provide a non-empty title or message", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.CustomNotificationRequest" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Invalid request body", + "schema": { + "$ref": "#/definitions/codersdk.Response" + } + }, + "403": { + "description": "System users cannot send custom notifications", + "schema": { + "$ref": "#/definitions/codersdk.Response" + } + }, + "500": { + "description": "Failed to send custom notification", + "schema": { + "$ref": "#/definitions/codersdk.Response" + } + } + } + } + }, "/notifications/dispatch-methods": { "get": { "security": [ @@ -1926,6 +1980,40 @@ const docTemplate = `{ } } }, + "/notifications/templates/custom": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Notifications" + ], + "summary": "Get custom notification templates", + "operationId": "get-custom-notification-templates", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.NotificationTemplate" + } + } + }, + "500": { + "description": "Failed to retrieve 'custom' notifications template", + "schema": { + "$ref": "#/definitions/codersdk.Response" + } + } + } + } + }, "/notifications/templates/system": { "get": { "security": [ @@ -1950,6 +2038,12 @@ const docTemplate = `{ "$ref": "#/definitions/codersdk.NotificationTemplate" } } + }, + "500": { + "description": "Failed to retrieve 'system' notifications template", + "schema": { + "$ref": "#/definitions/codersdk.Response" + } } } } @@ -12451,6 +12545,25 @@ const docTemplate = `{ "CryptoKeyFeatureTailnetResume" ] }, + "codersdk.CustomNotificationContent": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "title": { + "type": "string" + } + } + }, + "codersdk.CustomNotificationRequest": { + "type": "object", + "properties": { + "content": { + "$ref": "#/definitions/codersdk.CustomNotificationContent" + } + } + }, "codersdk.CustomRoleRequest": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index f28fa9478e045..1d83a08471a80 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -1456,6 +1456,54 @@ } } }, + "/notifications/custom": { + "post": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["Notifications"], + "summary": "Send a custom notification", + "operationId": "send-a-custom-notification", + "parameters": [ + { + "description": "Provide a non-empty title or message", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.CustomNotificationRequest" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Invalid request body", + "schema": { + "$ref": "#/definitions/codersdk.Response" + } + }, + "403": { + "description": "System users cannot send custom notifications", + "schema": { + "$ref": "#/definitions/codersdk.Response" + } + }, + "500": { + "description": "Failed to send custom notification", + "schema": { + "$ref": "#/definitions/codersdk.Response" + } + } + } + } + }, "/notifications/dispatch-methods": { "get": { "security": [ @@ -1678,6 +1726,36 @@ } } }, + "/notifications/templates/custom": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Notifications"], + "summary": "Get custom notification templates", + "operationId": "get-custom-notification-templates", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.NotificationTemplate" + } + } + }, + "500": { + "description": "Failed to retrieve 'custom' notifications template", + "schema": { + "$ref": "#/definitions/codersdk.Response" + } + } + } + } + }, "/notifications/templates/system": { "get": { "security": [ @@ -1698,6 +1776,12 @@ "$ref": "#/definitions/codersdk.NotificationTemplate" } } + }, + "500": { + "description": "Failed to retrieve 'system' notifications template", + "schema": { + "$ref": "#/definitions/codersdk.Response" + } } } } @@ -11106,6 +11190,25 @@ "CryptoKeyFeatureTailnetResume" ] }, + "codersdk.CustomNotificationContent": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "title": { + "type": "string" + } + } + }, + "codersdk.CustomNotificationRequest": { + "type": "object", + "properties": { + "content": { + "$ref": "#/definitions/codersdk.CustomNotificationContent" + } + } + }, "codersdk.CustomRoleRequest": { "type": "object", "properties": { diff --git a/coderd/coderd.go b/coderd/coderd.go index 7b30b20c74cce..6f80286395eb8 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1578,9 +1578,11 @@ func New(options *Options) *API { r.Put("/settings", api.putNotificationsSettings) r.Route("/templates", func(r chi.Router) { r.Get("/system", api.systemNotificationTemplates) + r.Get("/custom", api.customNotificationTemplates) }) r.Get("/dispatch-methods", api.notificationDispatchMethods) r.Post("/test", api.postTestNotification) + r.Post("/custom", api.postCustomNotification) }) r.Route("/tailnet", func(r chi.Router) { r.Use(apiKeyMiddleware) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 2486c8e715f3c..f746b9f8d69a5 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -2281,8 +2281,8 @@ func (q *querier) GetNotificationTemplateByID(ctx context.Context, id uuid.UUID) } func (q *querier) GetNotificationTemplatesByKind(ctx context.Context, kind database.NotificationTemplateKind) ([]database.NotificationTemplate, error) { - // Anyone can read the system notification templates. - if kind == database.NotificationTemplateKindSystem { + // Anyone can read the 'system' and 'custom' notification templates. + if kind == database.NotificationTemplateKindSystem || kind == database.NotificationTemplateKindCustom { return q.db.GetNotificationTemplatesByKind(ctx, kind) } diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 73120c8917172..208ff53d8fed2 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -150,7 +150,8 @@ CREATE TYPE notification_method AS ENUM ( ); CREATE TYPE notification_template_kind AS ENUM ( - 'system' + 'system', + 'custom' ); CREATE TYPE parameter_destination_scheme AS ENUM ( diff --git a/coderd/database/migrations/000368_add_custom_notifications.down.sql b/coderd/database/migrations/000368_add_custom_notifications.down.sql new file mode 100644 index 0000000000000..95f1a94bdb526 --- /dev/null +++ b/coderd/database/migrations/000368_add_custom_notifications.down.sql @@ -0,0 +1,15 @@ +-- Remove Custom Notification template +DELETE FROM notification_templates WHERE id = '39b1e189-c857-4b0c-877a-511144c18516'; + +-- Recreate the old enum without 'custom' +CREATE TYPE old_notification_template_kind AS ENUM ('system'); + +-- Update notification_templates to use the old enum +ALTER TABLE notification_templates + ALTER COLUMN kind DROP DEFAULT, + ALTER COLUMN kind TYPE old_notification_template_kind USING (kind::text::old_notification_template_kind), + ALTER COLUMN kind SET DEFAULT 'system'::old_notification_template_kind; + +-- Drop the current enum and restore the original name +DROP TYPE notification_template_kind; +ALTER TYPE old_notification_template_kind RENAME TO notification_template_kind; diff --git a/coderd/database/migrations/000368_add_custom_notifications.up.sql b/coderd/database/migrations/000368_add_custom_notifications.up.sql new file mode 100644 index 0000000000000..f6fe12f80915d --- /dev/null +++ b/coderd/database/migrations/000368_add_custom_notifications.up.sql @@ -0,0 +1,38 @@ +-- Create new enum with 'custom' value +CREATE TYPE new_notification_template_kind AS ENUM ( + 'system', + 'custom' +); + +-- Update the notification_templates table to use new enum +ALTER TABLE notification_templates + ALTER COLUMN kind DROP DEFAULT, + ALTER COLUMN kind TYPE new_notification_template_kind USING (kind::text::new_notification_template_kind), + ALTER COLUMN kind SET DEFAULT 'system'::new_notification_template_kind; + +-- Drop old enum and rename new one +DROP TYPE notification_template_kind; +ALTER TYPE new_notification_template_kind RENAME TO notification_template_kind; + +-- Insert new Custom Notification template with 'custom' kind +INSERT INTO notification_templates ( + id, + name, + title_template, + body_template, + actions, + "group", + method, + kind, + enabled_by_default +) VALUES ( + '39b1e189-c857-4b0c-877a-511144c18516', + 'Custom Notification', + '{{.Labels.custom_title}}', + '{{.Labels.custom_message}}', + '[]', + 'Custom Events', + NULL, + 'custom'::notification_template_kind, + true +); diff --git a/coderd/database/models.go b/coderd/database/models.go index 8431f49c6f196..7edc4277e4812 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -1201,6 +1201,7 @@ type NotificationTemplateKind string const ( NotificationTemplateKindSystem NotificationTemplateKind = "system" + NotificationTemplateKindCustom NotificationTemplateKind = "custom" ) func (e *NotificationTemplateKind) Scan(src interface{}) error { @@ -1240,7 +1241,8 @@ func (ns NullNotificationTemplateKind) Value() (driver.Value, error) { func (e NotificationTemplateKind) Valid() bool { switch e { - case NotificationTemplateKindSystem: + case NotificationTemplateKindSystem, + NotificationTemplateKindCustom: return true } return false @@ -1249,6 +1251,7 @@ func (e NotificationTemplateKind) Valid() bool { func AllNotificationTemplateKindValues() []NotificationTemplateKind { return []NotificationTemplateKind{ NotificationTemplateKindSystem, + NotificationTemplateKindCustom, } } diff --git a/coderd/notifications.go b/coderd/notifications.go index 670f3625f41bc..e09dd2d69ceca 100644 --- a/coderd/notifications.go +++ b/coderd/notifications.go @@ -3,7 +3,9 @@ package coderd import ( "bytes" "encoding/json" + "fmt" "net/http" + "time" "github.com/google/uuid" @@ -124,20 +126,14 @@ func (api *API) putNotificationsSettings(rw http.ResponseWriter, r *http.Request httpapi.Write(r.Context(), rw, http.StatusOK, settings) } -// @Summary Get system notification templates -// @ID get-system-notification-templates -// @Security CoderSessionToken -// @Produce json -// @Tags Notifications -// @Success 200 {array} codersdk.NotificationTemplate -// @Router /notifications/templates/system [get] -func (api *API) systemNotificationTemplates(rw http.ResponseWriter, r *http.Request) { +// notificationTemplatesByKind gets the notification templates by kind +func (api *API) notificationTemplatesByKind(rw http.ResponseWriter, r *http.Request, kind database.NotificationTemplateKind) { ctx := r.Context() - templates, err := api.Database.GetNotificationTemplatesByKind(ctx, database.NotificationTemplateKindSystem) + templates, err := api.Database.GetNotificationTemplatesByKind(ctx, kind) if err != nil { httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to retrieve system notifications templates.", + Message: fmt.Sprintf("Failed to retrieve %q notifications templates.", kind), Detail: err.Error(), }) return @@ -147,6 +143,30 @@ func (api *API) systemNotificationTemplates(rw http.ResponseWriter, r *http.Requ httpapi.Write(r.Context(), rw, http.StatusOK, out) } +// @Summary Get system notification templates +// @ID get-system-notification-templates +// @Security CoderSessionToken +// @Produce json +// @Tags Notifications +// @Success 200 {array} codersdk.NotificationTemplate +// @Failure 500 {object} codersdk.Response "Failed to retrieve 'system' notifications template" +// @Router /notifications/templates/system [get] +func (api *API) systemNotificationTemplates(rw http.ResponseWriter, r *http.Request) { + api.notificationTemplatesByKind(rw, r, database.NotificationTemplateKindSystem) +} + +// @Summary Get custom notification templates +// @ID get-custom-notification-templates +// @Security CoderSessionToken +// @Produce json +// @Tags Notifications +// @Success 200 {array} codersdk.NotificationTemplate +// @Failure 500 {object} codersdk.Response "Failed to retrieve 'custom' notifications template" +// @Router /notifications/templates/custom [get] +func (api *API) customNotificationTemplates(rw http.ResponseWriter, r *http.Request) { + api.notificationTemplatesByKind(rw, r, database.NotificationTemplateKindCustom) +} + // @Summary Get notification dispatch methods // @ID get-notification-dispatch-methods // @Security CoderSessionToken @@ -323,6 +343,91 @@ func (api *API) putUserNotificationPreferences(rw http.ResponseWriter, r *http.R httpapi.Write(ctx, rw, http.StatusOK, out) } +// @Summary Send a custom notification +// @ID send-a-custom-notification +// @Security CoderSessionToken +// @Tags Notifications +// @Accept json +// @Produce json +// @Param request body codersdk.CustomNotificationRequest true "Provide a non-empty title or message" +// @Success 204 "No Content" +// @Failure 400 {object} codersdk.Response "Invalid request body" +// @Failure 403 {object} codersdk.Response "System users cannot send custom notifications" +// @Failure 500 {object} codersdk.Response "Failed to send custom notification" +// @Router /notifications/custom [post] +func (api *API) postCustomNotification(rw http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + apiKey = httpmw.APIKey(r) + ) + + // Parse request + var req codersdk.CustomNotificationRequest + if !httpapi.Read(ctx, rw, r, &req) { + return + } + + // Validate request: require `content` and non-empty `title` and `message` + if err := req.Validate(); err != nil { + api.Logger.Error(ctx, "send custom notification: validation failed", slog.Error(err)) + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid request body", + Detail: err.Error(), + }) + return + } + + // Block system users from sending custom notifications + user, err := api.Database.GetUserByID(ctx, apiKey.UserID) + if err != nil { + api.Logger.Error(ctx, "send custom notification", slog.Error(err)) + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to send custom notification", + Detail: err.Error(), + }) + return + } + if user.IsSystem { + api.Logger.Error(ctx, "send custom notification: system user is not allowed", + slog.F("id", user.ID.String()), slog.F("name", user.Name)) + httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{ + Message: "Forbidden", + Detail: "System users cannot send custom notifications.", + }) + return + } + + if _, err := api.NotificationsEnqueuer.EnqueueWithData( + //nolint:gocritic // We need to be notifier to send the notification. + dbauthz.AsNotifier(ctx), + user.ID, + notifications.TemplateCustomNotification, + map[string]string{ + "custom_title": req.Content.Title, + "custom_message": req.Content.Message, + }, + map[string]any{ + // Current dedupe is done via an hash of (template, user, method, payload, targets, day). + // Include a minute-bucketed timestamp to bypass per-day dedupe for self-sends, + // letting the caller resend identical content the same day (but not more than + // once per minute). + // TODO(ssncferreira): When custom notifications can target multiple users/roles, + // enforce proper deduplication across recipients to reduce noise and prevent spam. + "dedupe_bypass_ts": api.Clock.Now().UTC().Truncate(time.Minute), + }, + user.ID.String(), + ); err != nil { + api.Logger.Error(ctx, "send custom notification", slog.Error(err)) + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to send custom notification", + Detail: err.Error(), + }) + return + } + + rw.WriteHeader(http.StatusNoContent) +} + func convertNotificationTemplates(in []database.NotificationTemplate) (out []codersdk.NotificationTemplate) { for _, tmpl := range in { out = append(out, codersdk.NotificationTemplate{ diff --git a/coderd/notifications/events.go b/coderd/notifications/events.go index 0e88361b56f68..9c92a1a622deb 100644 --- a/coderd/notifications/events.go +++ b/coderd/notifications/events.go @@ -49,5 +49,6 @@ var ( // Notification-related events. var ( - TemplateTestNotification = uuid.MustParse("c425f63e-716a-4bf4-ae24-78348f706c3f") + TemplateTestNotification = uuid.MustParse("c425f63e-716a-4bf4-ae24-78348f706c3f") + TemplateCustomNotification = uuid.MustParse("39b1e189-c857-4b0c-877a-511144c18516") ) diff --git a/coderd/notifications/notifications_test.go b/coderd/notifications/notifications_test.go index f5e72a8327d7e..c3aa1fd72c4b3 100644 --- a/coderd/notifications/notifications_test.go +++ b/coderd/notifications/notifications_test.go @@ -1258,6 +1258,20 @@ func TestNotificationTemplates_Golden(t *testing.T) { Data: map[string]any{}, }, }, + { + name: "TemplateCustomNotification", + id: notifications.TemplateCustomNotification, + payload: types.MessagePayload{ + UserName: "Bobby", + UserEmail: "bobby@coder.com", + UserUsername: "bobby", + Labels: map[string]string{ + "custom_title": "Custom Title", + "custom_message": "Custom Message", + }, + Data: map[string]any{}, + }, + }, } // We must have a test case for every notification_template. This is enforced below: diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateCustomNotification.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateCustomNotification.html.golden new file mode 100644 index 0000000000000..bf749a4b9ce42 --- /dev/null +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateCustomNotification.html.golden @@ -0,0 +1,68 @@ +From: system@coder.com +To: bobby@coder.com +Subject: Custom Title +Message-Id: 02ee4935-73be-4fa1-a290-ff9999026b13@blush-whale-48 +Date: Fri, 11 Oct 2024 09:03:06 +0000 +Content-Type: multipart/alternative; boundary=bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4 +MIME-Version: 1.0 + +--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4 +Content-Transfer-Encoding: quoted-printable +Content-Type: text/plain; charset=UTF-8 + +Hi Bobby, + +Custom Message + + +--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4 +Content-Transfer-Encoding: quoted-printable +Content-Type: text/html; charset=UTF-8 + +<!doctype html> +<html lang=3D"en"> + <head> + <meta charset=3D"UTF-8" /> + <meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale= +=3D1.0" /> + <title>Custom Title + + +
+
+ 3D"Cod= +
+

+ Custom Title +

+
+

Hi Bobby,

+

Custom Message

+
+
+ =20 +
+
+

© 2024 Coder. All rights reserved - h= +ttp://test.com

+

Click here to manage your notification = +settings

+

Stop receiving emails like this

+
+
+ + + +--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4-- diff --git a/coderd/notifications/testdata/rendered-templates/webhook/TemplateCustomNotification.json.golden b/coderd/notifications/testdata/rendered-templates/webhook/TemplateCustomNotification.json.golden new file mode 100644 index 0000000000000..66aba4bfbbce5 --- /dev/null +++ b/coderd/notifications/testdata/rendered-templates/webhook/TemplateCustomNotification.json.golden @@ -0,0 +1,24 @@ +{ + "_version": "1.1", + "msg_id": "00000000-0000-0000-0000-000000000000", + "payload": { + "_version": "1.2", + "notification_name": "Custom Notification", + "notification_template_id": "00000000-0000-0000-0000-000000000000", + "user_id": "00000000-0000-0000-0000-000000000000", + "user_email": "bobby@coder.com", + "user_name": "Bobby", + "user_username": "bobby", + "actions": [], + "labels": { + "custom_message": "Custom Message", + "custom_title": "Custom Title" + }, + "data": {}, + "targets": null + }, + "title": "Custom Title", + "title_markdown": "Custom Title", + "body": "Custom Message", + "body_markdown": "Custom Message" +} \ No newline at end of file diff --git a/coderd/notifications_test.go b/coderd/notifications_test.go index bae8b8827fe79..f1a081b3e8a89 100644 --- a/coderd/notifications_test.go +++ b/coderd/notifications_test.go @@ -11,6 +11,7 @@ import ( "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/notifications" "github.com/coder/coder/v2/coderd/notifications/notificationstest" "github.com/coder/coder/v2/codersdk" @@ -376,3 +377,113 @@ func TestNotificationTest(t *testing.T) { require.Len(t, sent, 0) }) } + +func TestCustomNotification(t *testing.T) { + t.Parallel() + + t.Run("BadRequest", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + + notifyEnq := ¬ificationstest.FakeEnqueuer{} + ownerClient := coderdtest.New(t, &coderdtest.Options{ + DeploymentValues: coderdtest.DeploymentValues(t), + NotificationsEnqueuer: notifyEnq, + }) + + // Given: A member user + ownerUser := coderdtest.CreateFirstUser(t, ownerClient) + memberClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, ownerUser.OrganizationID) + + // When: The member user attempts to send a custom notification with empty title and message + err := memberClient.PostCustomNotification(ctx, codersdk.CustomNotificationRequest{ + Content: &codersdk.CustomNotificationContent{ + Title: "", + Message: "", + }, + }) + + // Then: a bad request error is expected with no notifications sent + var sdkError *codersdk.Error + require.Error(t, err) + require.ErrorAsf(t, err, &sdkError, "error should be of type *codersdk.Error") + require.Equal(t, http.StatusBadRequest, sdkError.StatusCode()) + require.Equal(t, "Invalid request body", sdkError.Message) + + sent := notifyEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateTestNotification)) + require.Len(t, sent, 0) + }) + + t.Run("SystemUserNotAllowed", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + + notifyEnq := ¬ificationstest.FakeEnqueuer{} + ownerClient, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{ + DeploymentValues: coderdtest.DeploymentValues(t), + NotificationsEnqueuer: notifyEnq, + }) + + // Given: A system user (prebuilds system user) + _, token := dbgen.APIKey(t, db, database.APIKey{ + UserID: database.PrebuildsSystemUserID, + LoginType: database.LoginTypeNone, + }) + systemUserClient := codersdk.New(ownerClient.URL) + systemUserClient.SetSessionToken(token) + + // When: The system user attempts to send a custom notification + err := systemUserClient.PostCustomNotification(ctx, codersdk.CustomNotificationRequest{ + Content: &codersdk.CustomNotificationContent{ + Title: "Custom Title", + Message: "Custom Message", + }, + }) + + // Then: a forbidden error is expected with no notifications sent + var sdkError *codersdk.Error + require.Error(t, err) + require.ErrorAsf(t, err, &sdkError, "error should be of type *codersdk.Error") + require.Equal(t, http.StatusForbidden, sdkError.StatusCode()) + require.Equal(t, "Forbidden", sdkError.Message) + + sent := notifyEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateTestNotification)) + require.Len(t, sent, 0) + }) + + t.Run("Success", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + + notifyEnq := ¬ificationstest.FakeEnqueuer{} + ownerClient := coderdtest.New(t, &coderdtest.Options{ + DeploymentValues: coderdtest.DeploymentValues(t), + NotificationsEnqueuer: notifyEnq, + }) + + // Given: A member user + ownerUser := coderdtest.CreateFirstUser(t, ownerClient) + memberClient, memberUser := coderdtest.CreateAnotherUser(t, ownerClient, ownerUser.OrganizationID) + + // When: The member user attempts to send a custom notification + err := memberClient.PostCustomNotification(ctx, codersdk.CustomNotificationRequest{ + Content: &codersdk.CustomNotificationContent{ + Title: "Custom Title", + Message: "Custom Message", + }, + }) + require.NoError(t, err) + + // Then: we expect a custom notification to be sent to the member user + sent := notifyEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateCustomNotification)) + require.Len(t, sent, 1) + require.Equal(t, memberUser.ID, sent[0].UserID) + require.Len(t, sent[0].Labels, 2) + require.Equal(t, "Custom Title", sent[0].Labels["custom_title"]) + require.Equal(t, "Custom Message", sent[0].Labels["custom_message"]) + require.Equal(t, memberUser.ID.String(), sent[0].CreatedBy) + }) +} diff --git a/codersdk/notifications.go b/codersdk/notifications.go index 9d68c5a01d9c6..9128c4cce26e3 100644 --- a/codersdk/notifications.go +++ b/codersdk/notifications.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "net/http" + "strings" "time" "github.com/google/uuid" @@ -280,3 +281,53 @@ func (c *Client) PostTestWebpushMessage(ctx context.Context) error { } return nil } + +type CustomNotificationContent struct { + Title string `json:"title"` + Message string `json:"message"` +} + +type CustomNotificationRequest struct { + Content *CustomNotificationContent `json:"content"` + // TODO(ssncferreira): Add target (user_ids, roles) to support multi-user and role-based delivery. + // See: https://github.com/coder/coder/issues/19768 +} + +const ( + maxCustomNotificationTitleLen = 120 + maxCustomNotificationMessageLen = 2000 +) + +func (c CustomNotificationRequest) Validate() error { + if c.Content == nil { + return xerrors.Errorf("content is required") + } + return c.Content.Validate() +} + +func (c CustomNotificationContent) Validate() error { + if strings.TrimSpace(c.Title) == "" || + strings.TrimSpace(c.Message) == "" { + return xerrors.Errorf("provide a non-empty 'content.title' and 'content.message'") + } + if len(c.Title) > maxCustomNotificationTitleLen { + return xerrors.Errorf("'content.title' must be less than %d characters", maxCustomNotificationTitleLen) + } + if len(c.Message) > maxCustomNotificationMessageLen { + return xerrors.Errorf("'content.message' must be less than %d characters", maxCustomNotificationMessageLen) + } + return nil +} + +func (c *Client) PostCustomNotification(ctx context.Context, req CustomNotificationRequest) error { + res, err := c.Request(ctx, http.MethodPost, "/api/v2/notifications/custom", req) + if err != nil { + return err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusNoContent { + return ReadBodyAsError(res) + } + return nil +} diff --git a/docs/admin/monitoring/notifications/index.md b/docs/admin/monitoring/notifications/index.md index 70279dcb16bf1..e87a4dd1ac27d 100644 --- a/docs/admin/monitoring/notifications/index.md +++ b/docs/admin/monitoring/notifications/index.md @@ -143,9 +143,11 @@ After setting the required fields above: ```text CODER_EMAIL_SMARTHOST=smtp.gmail.com:465 CODER_EMAIL_AUTH_USERNAME=@ - CODER_EMAIL_AUTH_PASSWORD="" + CODER_EMAIL_AUTH_PASSWORD="" ``` + **Note:** The `CODER_EMAIL_AUTH_PASSWORD` must be entered without spaces. + See [this help article from Google](https://support.google.com/a/answer/176600?hl=en) for more options. @@ -261,6 +263,43 @@ Administrators can configure which delivery methods are used for each different You can find this page under `https://$CODER_ACCESS_URL/deployment/notifications?tab=events`. +## Custom notifications + +Custom notifications let you send an ad‑hoc notification to yourself using the Coder CLI. +These are useful for surfacing the result of long-running tasks or important state changes. +At this time, custom notifications can only be sent to the user making the request. + +To send a custom notification, execute [`coder notifications custom <message>`](../../../reference/cli/notifications_custom.md). + +<!-- TODO(ssncferreira): Update when sending custom notifications to multiple users/roles is supported. + Explain deduplication behaviour for multiple users/roles. + See: https://github.com/coder/coder/issues/19768 +--> +**Note:** The recipient is always the requesting user as targeting other users or groups isn’t supported yet. + +### Examples + +- Send yourself a quick update: + +```shell +coder templates push -y && coder notifications custom "Template push complete" "Template version uploaded." +``` + +- Use in a script after a long-running task: + +```shell +#!/usr/bin/env bash +set -o pipefail + +if make test 2>&1 | tee test_output.log; then + coder notifications custom "Tests Succeeded" $'Test results:\n • ✅ success' +else + failures=$(grep -Po '\d+(?=\s+failures)' test_output.log | tail -n1 || echo 0) + coder notifications custom "Tests Failed" $'Test results:\n • ❌ failed ('"$failures"' tests failed)' + exit 1 +fi +``` + ## Stop sending notifications Administrators may wish to stop _all_ notifications across the deployment. We diff --git a/docs/manifest.json b/docs/manifest.json index 9359fb6f1da33..a75ff6459ee5a 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -1287,6 +1287,11 @@ "description": "Manage Coder notifications", "path": "reference/cli/notifications.md" }, + { + "title": "notifications custom", + "description": "Send a custom notification", + "path": "reference/cli/notifications_custom.md" + }, { "title": "notifications pause", "description": "Pause notifications", diff --git a/docs/reference/api/notifications.md b/docs/reference/api/notifications.md index 09890d3b17864..df94b83c164cb 100644 --- a/docs/reference/api/notifications.md +++ b/docs/reference/api/notifications.md @@ -1,5 +1,64 @@ # Notifications +## Send a custom notification + +### Code samples + +```shell +# Example request using curl +curl -X POST http://coder-server:8080/api/v2/notifications/custom \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`POST /notifications/custom` + +> Body parameter + +```json +{ + "content": { + "message": "string", + "title": "string" + } +} +``` + +### Parameters + +| Name | In | Type | Required | Description | +|--------|------|------------------------------------------------------------------------------------|----------|--------------------------------------| +| `body` | body | [codersdk.CustomNotificationRequest](schemas.md#codersdkcustomnotificationrequest) | true | Provide a non-empty title or message | + +### Example responses + +> 400 Response + +```json +{ + "detail": "string", + "message": "string", + "validations": [ + { + "detail": "string", + "field": "string" + } + ] +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +|--------|----------------------------------------------------------------------------|-----------------------------------------------|--------------------------------------------------| +| 204 | [No Content](https://tools.ietf.org/html/rfc7231#section-6.3.5) | No Content | | +| 400 | [Bad Request](https://tools.ietf.org/html/rfc7231#section-6.5.1) | Invalid request body | [codersdk.Response](schemas.md#codersdkresponse) | +| 403 | [Forbidden](https://tools.ietf.org/html/rfc7231#section-6.5.3) | System users cannot send custom notifications | [codersdk.Response](schemas.md#codersdkresponse) | +| 500 | [Internal Server Error](https://tools.ietf.org/html/rfc7231#section-6.6.1) | Failed to send custom notification | [codersdk.Response](schemas.md#codersdkresponse) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Get notification dispatch methods ### Code samples @@ -315,6 +374,65 @@ curl -X PUT http://coder-server:8080/api/v2/notifications/settings \ To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Get custom notification templates + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/notifications/templates/custom \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /notifications/templates/custom` + +### Example responses + +> 200 Response + +```json +[ + { + "actions": "string", + "body_template": "string", + "enabled_by_default": true, + "group": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "kind": "string", + "method": "string", + "name": "string", + "title_template": "string" + } +] +``` + +### Responses + +| Status | Meaning | Description | Schema | +|--------|----------------------------------------------------------------------------|----------------------------------------------------|-----------------------------------------------------------------------------------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [codersdk.NotificationTemplate](schemas.md#codersdknotificationtemplate) | +| 500 | [Internal Server Error](https://tools.ietf.org/html/rfc7231#section-6.6.1) | Failed to retrieve 'custom' notifications template | [codersdk.Response](schemas.md#codersdkresponse) | + +<h3 id="get-custom-notification-templates-responseschema">Response Schema</h3> + +Status Code **200** + +| Name | Type | Required | Restrictions | Description | +|------------------------|--------------|----------|--------------|-------------| +| `[array item]` | array | false | | | +| `» actions` | string | false | | | +| `» body_template` | string | false | | | +| `» enabled_by_default` | boolean | false | | | +| `» group` | string | false | | | +| `» id` | string(uuid) | false | | | +| `» kind` | string | false | | | +| `» method` | string | false | | | +| `» name` | string | false | | | +| `» title_template` | string | false | | | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Get system notification templates ### Code samples @@ -350,9 +468,10 @@ curl -X GET http://coder-server:8080/api/v2/notifications/templates/system \ ### Responses -| Status | Meaning | Description | Schema | -|--------|---------------------------------------------------------|-------------|-----------------------------------------------------------------------------------| -| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [codersdk.NotificationTemplate](schemas.md#codersdknotificationtemplate) | +| Status | Meaning | Description | Schema | +|--------|----------------------------------------------------------------------------|----------------------------------------------------|-----------------------------------------------------------------------------------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [codersdk.NotificationTemplate](schemas.md#codersdknotificationtemplate) | +| 500 | [Internal Server Error](https://tools.ietf.org/html/rfc7231#section-6.6.1) | Failed to retrieve 'system' notifications template | [codersdk.Response](schemas.md#codersdkresponse) | <h3 id="get-system-notification-templates-responseschema">Response Schema</h3> diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index f06e7fbe8e09e..b3959ceafa503 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -1872,6 +1872,39 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | `oidc_convert` | | `tailnet_resume` | +## codersdk.CustomNotificationContent + +```json +{ + "message": "string", + "title": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|-----------|--------|----------|--------------|-------------| +| `message` | string | false | | | +| `title` | string | false | | | + +## codersdk.CustomNotificationRequest + +```json +{ + "content": { + "message": "string", + "title": "string" + } +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|-----------|--------------------------------------------------------------------------|----------|--------------|-------------| +| `content` | [codersdk.CustomNotificationContent](#codersdkcustomnotificationcontent) | false | | | + ## codersdk.CustomRoleRequest ```json diff --git a/docs/reference/cli/notifications.md b/docs/reference/cli/notifications.md index 14642fd8ddb9f..bb471754e4958 100644 --- a/docs/reference/cli/notifications.md +++ b/docs/reference/cli/notifications.md @@ -19,7 +19,7 @@ coder notifications Administrators can use these commands to change notification settings. - Pause Coder notifications. Administrators can temporarily stop notifiers from dispatching messages in case of the target outage (for example: unavailable SMTP -server or Webhook not responding).: +server or Webhook not responding): $ coder notifications pause @@ -28,15 +28,21 @@ server or Webhook not responding).: $ coder notifications resume - Send a test notification. Administrators can use this to verify the notification -target settings.: +target settings: $ coder notifications test + + - Send a custom notification to the requesting user. Sending notifications +targeting other users or groups is currently not supported: + + $ coder notifications custom "Custom Title" "Custom Message" ``` ## Subcommands -| Name | Purpose | -|--------------------------------------------------|--------------------------| -| [<code>pause</code>](./notifications_pause.md) | Pause notifications | -| [<code>resume</code>](./notifications_resume.md) | Resume notifications | -| [<code>test</code>](./notifications_test.md) | Send a test notification | +| Name | Purpose | +|--------------------------------------------------|----------------------------| +| [<code>pause</code>](./notifications_pause.md) | Pause notifications | +| [<code>resume</code>](./notifications_resume.md) | Resume notifications | +| [<code>test</code>](./notifications_test.md) | Send a test notification | +| [<code>custom</code>](./notifications_custom.md) | Send a custom notification | diff --git a/docs/reference/cli/notifications_custom.md b/docs/reference/cli/notifications_custom.md new file mode 100644 index 0000000000000..9b8eff39fc9c8 --- /dev/null +++ b/docs/reference/cli/notifications_custom.md @@ -0,0 +1,10 @@ +<!-- DO NOT EDIT | GENERATED CONTENT --> +# notifications custom + +Send a custom notification + +## Usage + +```console +coder notifications custom <title> <message> +``` diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index b61b311c60673..ccbe5924b5c00 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -629,6 +629,17 @@ export const CryptoKeyFeatures: CryptoKeyFeature[] = [ "workspace_apps_token", ]; +// From codersdk/notifications.go +export interface CustomNotificationContent { + readonly title: string; + readonly message: string; +} + +// From codersdk/notifications.go +export interface CustomNotificationRequest { + readonly content: CustomNotificationContent | null; +} + // From codersdk/roles.go export interface CustomRoleRequest { readonly name: string; @@ -4129,6 +4140,12 @@ export const annotationSecretKey = "secret"; // From codersdk/insights.go export const insightsTimeLayout = "2006-01-02T15:04:05Z07:00"; +// From codersdk/notifications.go +export const maxCustomNotificationMessageLen = 2000; + +// From codersdk/notifications.go +export const maxCustomNotificationTitleLen = 120; + // From healthsdk/interfaces.go export const safeMTU = 1378;