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
",
+ 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
+
+ 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
+
+
+
+
+
+
+ Custom Title
+
+
+
+
+

+
+
+ Custom Title
+
+
+
Hi Bobby,
+
Custom Message
+
+
+ =20
+
+
+
+
+
+
+--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 `](../../../reference/cli/notifications_custom.md).
+
+
+**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) |
+
+Response Schema
+
+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) |
Response Schema
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 |
-|--------------------------------------------------|--------------------------|
-| [pause
](./notifications_pause.md) | Pause notifications |
-| [resume
](./notifications_resume.md) | Resume notifications |
-| [test
](./notifications_test.md) | Send a test notification |
+| Name | Purpose |
+|--------------------------------------------------|----------------------------|
+| [pause
](./notifications_pause.md) | Pause notifications |
+| [resume
](./notifications_resume.md) | Resume notifications |
+| [test
](./notifications_test.md) | Send a test notification |
+| [custom
](./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 @@
+
+# notifications custom
+
+Send a custom notification
+
+## Usage
+
+```console
+coder notifications custom
+```
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;