feat: support custom notifications (#19751)

## Description

Adds support for sending an ad‑hoc custom notification to the
authenticated user via API and CLI. This is useful for surfacing the
result of scripts or long‑running tasks. Notifications are delivered
through the configured method and the dashboard Inbox, respecting
existing preferences and delivery settings.

## Changes

* New notification template: “Custom Notification” with a label for a
custom title and a custom message.
* New API endpoint: `POST /api/v2/notifications/custom` to send a custom
notification to the requesting user.
* New API endpoint: `GET /notifications/templates/custom` to get custom
notification template.
* New CLI subcommand: `coder notifications custom <title> <message>` to
send a custom notification to the requesting user.
* Documentation updates: Add a “Custom notifications” section under
Administration > Monitoring > Notifications, including instructions on
sending custom notifications and examples of when to use them.

Closes: https://github.com/coder/coder/issues/19611
This commit is contained in:
Susana Ferreira
2025-09-11 15:08:57 +02:00
committed by GitHub
parent 4c98decfb7
commit eec6c8c120
26 changed files with 1056 additions and 30 deletions
+34 -2
View File
@@ -16,7 +16,7 @@ func (r *RootCmd) notifications() *serpent.Command {
Short: "Manage Coder notifications", Short: "Manage Coder notifications",
Long: "Administrators can use these commands to change notification settings.\n" + FormatExamples( Long: "Administrators can use these commands to change notification settings.\n" + FormatExamples(
Example{ 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", Command: "coder notifications pause",
}, },
Example{ Example{
@@ -24,9 +24,13 @@ func (r *RootCmd) notifications() *serpent.Command {
Command: "coder notifications resume", Command: "coder notifications resume",
}, },
Example{ 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", 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"}, Aliases: []string{"notification"},
Handler: func(inv *serpent.Invocation) error { Handler: func(inv *serpent.Invocation) error {
@@ -36,6 +40,7 @@ func (r *RootCmd) notifications() *serpent.Command {
r.pauseNotifications(), r.pauseNotifications(),
r.resumeNotifications(), r.resumeNotifications(),
r.testNotifications(), r.testNotifications(),
r.customNotifications(),
}, },
} }
return cmd return cmd
@@ -109,3 +114,30 @@ func (r *RootCmd) testNotifications() *serpent.Command {
} }
return cmd return cmd
} }
func (r *RootCmd) customNotifications() *serpent.Command {
client := new(codersdk.Client)
cmd := &serpent.Command{
Use: "custom <title> <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
}
+101
View File
@@ -12,6 +12,8 @@ import (
"github.com/coder/coder/v2/cli/clitest" "github.com/coder/coder/v2/cli/clitest"
"github.com/coder/coder/v2/coderd/coderdtest" "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"
"github.com/coder/coder/v2/coderd/notifications/notificationstest" "github.com/coder/coder/v2/coderd/notifications/notificationstest"
"github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk"
@@ -166,3 +168,102 @@ func TestNotificationsTest(t *testing.T) {
require.Len(t, sent, 0) require.Len(t, sent, 0)
}) })
} }
func TestCustomNotifications(t *testing.T) {
t.Parallel()
t.Run("BadRequest", func(t *testing.T) {
t.Parallel()
notifyEnq := &notificationstest.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 := &notificationstest.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 := &notificationstest.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)
})
}
+8 -2
View File
@@ -12,7 +12,7 @@ USAGE:
from from
dispatching messages in case of the target outage (for example: unavailable dispatching messages in case of the target outage (for example: unavailable
SMTP SMTP
server or Webhook not responding).: server or Webhook not responding):
$ coder notifications pause $ coder notifications pause
@@ -22,11 +22,17 @@ USAGE:
- Send a test notification. Administrators can use this to verify the - Send a test notification. Administrators can use this to verify the
notification notification
target settings.: target settings:
$ coder notifications test $ 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: SUBCOMMANDS:
custom Send a custom notification
pause Pause notifications pause Pause notifications
resume Resume notifications resume Resume notifications
test Send a test notification test Send a test notification
+9
View File
@@ -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.
+113
View File
@@ -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": { "/notifications/dispatch-methods": {
"get": { "get": {
"security": [ "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": { "/notifications/templates/system": {
"get": { "get": {
"security": [ "security": [
@@ -1950,6 +2038,12 @@ const docTemplate = `{
"$ref": "#/definitions/codersdk.NotificationTemplate" "$ref": "#/definitions/codersdk.NotificationTemplate"
} }
} }
},
"500": {
"description": "Failed to retrieve 'system' notifications template",
"schema": {
"$ref": "#/definitions/codersdk.Response"
}
} }
} }
} }
@@ -12451,6 +12545,25 @@ const docTemplate = `{
"CryptoKeyFeatureTailnetResume" "CryptoKeyFeatureTailnetResume"
] ]
}, },
"codersdk.CustomNotificationContent": {
"type": "object",
"properties": {
"message": {
"type": "string"
},
"title": {
"type": "string"
}
}
},
"codersdk.CustomNotificationRequest": {
"type": "object",
"properties": {
"content": {
"$ref": "#/definitions/codersdk.CustomNotificationContent"
}
}
},
"codersdk.CustomRoleRequest": { "codersdk.CustomRoleRequest": {
"type": "object", "type": "object",
"properties": { "properties": {
+103
View File
@@ -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": { "/notifications/dispatch-methods": {
"get": { "get": {
"security": [ "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": { "/notifications/templates/system": {
"get": { "get": {
"security": [ "security": [
@@ -1698,6 +1776,12 @@
"$ref": "#/definitions/codersdk.NotificationTemplate" "$ref": "#/definitions/codersdk.NotificationTemplate"
} }
} }
},
"500": {
"description": "Failed to retrieve 'system' notifications template",
"schema": {
"$ref": "#/definitions/codersdk.Response"
}
} }
} }
} }
@@ -11106,6 +11190,25 @@
"CryptoKeyFeatureTailnetResume" "CryptoKeyFeatureTailnetResume"
] ]
}, },
"codersdk.CustomNotificationContent": {
"type": "object",
"properties": {
"message": {
"type": "string"
},
"title": {
"type": "string"
}
}
},
"codersdk.CustomNotificationRequest": {
"type": "object",
"properties": {
"content": {
"$ref": "#/definitions/codersdk.CustomNotificationContent"
}
}
},
"codersdk.CustomRoleRequest": { "codersdk.CustomRoleRequest": {
"type": "object", "type": "object",
"properties": { "properties": {
+2
View File
@@ -1578,9 +1578,11 @@ func New(options *Options) *API {
r.Put("/settings", api.putNotificationsSettings) r.Put("/settings", api.putNotificationsSettings)
r.Route("/templates", func(r chi.Router) { r.Route("/templates", func(r chi.Router) {
r.Get("/system", api.systemNotificationTemplates) r.Get("/system", api.systemNotificationTemplates)
r.Get("/custom", api.customNotificationTemplates)
}) })
r.Get("/dispatch-methods", api.notificationDispatchMethods) r.Get("/dispatch-methods", api.notificationDispatchMethods)
r.Post("/test", api.postTestNotification) r.Post("/test", api.postTestNotification)
r.Post("/custom", api.postCustomNotification)
}) })
r.Route("/tailnet", func(r chi.Router) { r.Route("/tailnet", func(r chi.Router) {
r.Use(apiKeyMiddleware) r.Use(apiKeyMiddleware)
+2 -2
View File
@@ -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) { func (q *querier) GetNotificationTemplatesByKind(ctx context.Context, kind database.NotificationTemplateKind) ([]database.NotificationTemplate, error) {
// Anyone can read the system notification templates. // Anyone can read the 'system' and 'custom' notification templates.
if kind == database.NotificationTemplateKindSystem { if kind == database.NotificationTemplateKindSystem || kind == database.NotificationTemplateKindCustom {
return q.db.GetNotificationTemplatesByKind(ctx, kind) return q.db.GetNotificationTemplatesByKind(ctx, kind)
} }
+2 -1
View File
@@ -150,7 +150,8 @@ CREATE TYPE notification_method AS ENUM (
); );
CREATE TYPE notification_template_kind AS ENUM ( CREATE TYPE notification_template_kind AS ENUM (
'system' 'system',
'custom'
); );
CREATE TYPE parameter_destination_scheme AS ENUM ( CREATE TYPE parameter_destination_scheme AS ENUM (
@@ -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;
@@ -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
);
+4 -1
View File
@@ -1201,6 +1201,7 @@ type NotificationTemplateKind string
const ( const (
NotificationTemplateKindSystem NotificationTemplateKind = "system" NotificationTemplateKindSystem NotificationTemplateKind = "system"
NotificationTemplateKindCustom NotificationTemplateKind = "custom"
) )
func (e *NotificationTemplateKind) Scan(src interface{}) error { func (e *NotificationTemplateKind) Scan(src interface{}) error {
@@ -1240,7 +1241,8 @@ func (ns NullNotificationTemplateKind) Value() (driver.Value, error) {
func (e NotificationTemplateKind) Valid() bool { func (e NotificationTemplateKind) Valid() bool {
switch e { switch e {
case NotificationTemplateKindSystem: case NotificationTemplateKindSystem,
NotificationTemplateKindCustom:
return true return true
} }
return false return false
@@ -1249,6 +1251,7 @@ func (e NotificationTemplateKind) Valid() bool {
func AllNotificationTemplateKindValues() []NotificationTemplateKind { func AllNotificationTemplateKindValues() []NotificationTemplateKind {
return []NotificationTemplateKind{ return []NotificationTemplateKind{
NotificationTemplateKindSystem, NotificationTemplateKindSystem,
NotificationTemplateKindCustom,
} }
} }
+115 -10
View File
@@ -3,7 +3,9 @@ package coderd
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"time"
"github.com/google/uuid" "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) httpapi.Write(r.Context(), rw, http.StatusOK, settings)
} }
// @Summary Get system notification templates // notificationTemplatesByKind gets the notification templates by kind
// @ID get-system-notification-templates func (api *API) notificationTemplatesByKind(rw http.ResponseWriter, r *http.Request, kind database.NotificationTemplateKind) {
// @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) {
ctx := r.Context() ctx := r.Context()
templates, err := api.Database.GetNotificationTemplatesByKind(ctx, database.NotificationTemplateKindSystem) templates, err := api.Database.GetNotificationTemplatesByKind(ctx, kind)
if err != nil { if err != nil {
httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{ 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(), Detail: err.Error(),
}) })
return return
@@ -147,6 +143,30 @@ func (api *API) systemNotificationTemplates(rw http.ResponseWriter, r *http.Requ
httpapi.Write(r.Context(), rw, http.StatusOK, out) 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 // @Summary Get notification dispatch methods
// @ID get-notification-dispatch-methods // @ID get-notification-dispatch-methods
// @Security CoderSessionToken // @Security CoderSessionToken
@@ -323,6 +343,91 @@ func (api *API) putUserNotificationPreferences(rw http.ResponseWriter, r *http.R
httpapi.Write(ctx, rw, http.StatusOK, out) 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) { func convertNotificationTemplates(in []database.NotificationTemplate) (out []codersdk.NotificationTemplate) {
for _, tmpl := range in { for _, tmpl := range in {
out = append(out, codersdk.NotificationTemplate{ out = append(out, codersdk.NotificationTemplate{
+2 -1
View File
@@ -49,5 +49,6 @@ var (
// Notification-related events. // Notification-related events.
var ( 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")
) )
@@ -1258,6 +1258,20 @@ func TestNotificationTemplates_Golden(t *testing.T) {
Data: map[string]any{}, 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: // We must have a test case for every notification_template. This is enforced below:
@@ -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</title>
</head>
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
l', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; color: #020617=
; background: #f8fafc;">
<div style=3D"max-width: 600px; margin: 20px auto; padding: 60px; borde=
r: 1px solid #e2e8f0; border-radius: 8px; background-color: #fff; text-alig=
n: left; font-size: 14px; line-height: 1.5;">
<div style=3D"text-align: center;">
<img src=3D"https://coder.com/coder-logo-horizontal.png" alt=3D"Cod=
er Logo" style=3D"height: 40px;" />
</div>
<h1 style=3D"text-align: center; font-size: 24px; font-weight: 400; m=
argin: 8px 0 32px; line-height: 1.5;">
Custom Title
</h1>
<div style=3D"line-height: 1.5;">
<p>Hi Bobby,</p>
<p>Custom Message</p>
</div>
<div style=3D"text-align: center; margin-top: 32px;">
=20
</div>
<div style=3D"border-top: 1px solid #e2e8f0; color: #475569; font-siz=
e: 12px; margin-top: 64px; padding-top: 24px; line-height: 1.6;">
<p>&copy;&nbsp;2024&nbsp;Coder. All rights reserved&nbsp;-&nbsp;<a =
href=3D"http://test.com" style=3D"color: #2563eb; text-decoration: none;">h=
ttp://test.com</a></p>
<p><a href=3D"http://test.com/settings/notifications" style=3D"colo=
r: #2563eb; text-decoration: none;">Click here to manage your notification =
settings</a></p>
<p><a href=3D"http://test.com/settings/notifications?disabled=3D39b=
1e189-c857-4b0c-877a-511144c18516" style=3D"color: #2563eb; text-decoration=
: none;">Stop receiving emails like this</a></p>
</div>
</div>
</body>
</html>
--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4--
@@ -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"
}
+111
View File
@@ -11,6 +11,7 @@ import (
"github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database" "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"
"github.com/coder/coder/v2/coderd/notifications/notificationstest" "github.com/coder/coder/v2/coderd/notifications/notificationstest"
"github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk"
@@ -376,3 +377,113 @@ func TestNotificationTest(t *testing.T) {
require.Len(t, sent, 0) 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 := &notificationstest.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 := &notificationstest.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 := &notificationstest.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)
})
}
+51
View File
@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"strings"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
@@ -280,3 +281,53 @@ func (c *Client) PostTestWebpushMessage(ctx context.Context) error {
} }
return nil 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
}
+40 -1
View File
@@ -143,9 +143,11 @@ After setting the required fields above:
```text ```text
CODER_EMAIL_SMARTHOST=smtp.gmail.com:465 CODER_EMAIL_SMARTHOST=smtp.gmail.com:465
CODER_EMAIL_AUTH_USERNAME=<user>@<domain> CODER_EMAIL_AUTH_USERNAME=<user>@<domain>
CODER_EMAIL_AUTH_PASSWORD="<app password created above>" CODER_EMAIL_AUTH_PASSWORD="<app password created above (no spaces)>"
``` ```
**Note:** The `CODER_EMAIL_AUTH_PASSWORD` must be entered without spaces.
See See
[this help article from Google](https://support.google.com/a/answer/176600?hl=en) [this help article from Google](https://support.google.com/a/answer/176600?hl=en)
for more options. for more options.
@@ -261,6 +263,43 @@ Administrators can configure which delivery methods are used for each different
You can find this page under You can find this page under
`https://$CODER_ACCESS_URL/deployment/notifications?tab=events`. `https://$CODER_ACCESS_URL/deployment/notifications?tab=events`.
## Custom notifications
Custom notifications let you send an adhoc 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 <title> <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 isnt 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 ## Stop sending notifications
Administrators may wish to stop _all_ notifications across the deployment. We Administrators may wish to stop _all_ notifications across the deployment. We
+5
View File
@@ -1287,6 +1287,11 @@
"description": "Manage Coder notifications", "description": "Manage Coder notifications",
"path": "reference/cli/notifications.md" "path": "reference/cli/notifications.md"
}, },
{
"title": "notifications custom",
"description": "Send a custom notification",
"path": "reference/cli/notifications_custom.md"
},
{ {
"title": "notifications pause", "title": "notifications pause",
"description": "Pause notifications", "description": "Pause notifications",
+122 -3
View File
@@ -1,5 +1,64 @@
# Notifications # 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 ## Get notification dispatch methods
### Code samples ### 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). 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 ## Get system notification templates
### Code samples ### Code samples
@@ -350,9 +468,10 @@ curl -X GET http://coder-server:8080/api/v2/notifications/templates/system \
### Responses ### Responses
| Status | Meaning | Description | Schema | | Status | Meaning | Description | Schema |
|--------|---------------------------------------------------------|-------------|-----------------------------------------------------------------------------------| |--------|----------------------------------------------------------------------------|----------------------------------------------------|-----------------------------------------------------------------------------------|
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [codersdk.NotificationTemplate](schemas.md#codersdknotificationtemplate) | | 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> <h3 id="get-system-notification-templates-responseschema">Response Schema</h3>
+33
View File
@@ -1872,6 +1872,39 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
| `oidc_convert` | | `oidc_convert` |
| `tailnet_resume` | | `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 ## codersdk.CustomRoleRequest
```json ```json
+13 -7
View File
@@ -19,7 +19,7 @@ coder notifications
Administrators can use these commands to change notification settings. Administrators can use these commands to change notification settings.
- Pause Coder notifications. Administrators can temporarily stop notifiers from - Pause Coder notifications. Administrators can temporarily stop notifiers from
dispatching messages in case of the target outage (for example: unavailable SMTP 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 $ coder notifications pause
@@ -28,15 +28,21 @@ server or Webhook not responding).:
$ coder notifications resume $ coder notifications resume
- Send a test notification. Administrators can use this to verify the notification - Send a test notification. Administrators can use this to verify the notification
target settings.: target settings:
$ coder notifications test $ 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 ## Subcommands
| Name | Purpose | | Name | Purpose |
|--------------------------------------------------|--------------------------| |--------------------------------------------------|----------------------------|
| [<code>pause</code>](./notifications_pause.md) | Pause notifications | | [<code>pause</code>](./notifications_pause.md) | Pause notifications |
| [<code>resume</code>](./notifications_resume.md) | Resume notifications | | [<code>resume</code>](./notifications_resume.md) | Resume notifications |
| [<code>test</code>](./notifications_test.md) | Send a test notification | | [<code>test</code>](./notifications_test.md) | Send a test notification |
| [<code>custom</code>](./notifications_custom.md) | Send a custom notification |
+10
View File
@@ -0,0 +1,10 @@
<!-- DO NOT EDIT | GENERATED CONTENT -->
# notifications custom
Send a custom notification
## Usage
```console
coder notifications custom <title> <message>
```
+17
View File
@@ -629,6 +629,17 @@ export const CryptoKeyFeatures: CryptoKeyFeature[] = [
"workspace_apps_token", "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 // From codersdk/roles.go
export interface CustomRoleRequest { export interface CustomRoleRequest {
readonly name: string; readonly name: string;
@@ -4129,6 +4140,12 @@ export const annotationSecretKey = "secret";
// From codersdk/insights.go // From codersdk/insights.go
export const insightsTimeLayout = "2006-01-02T15:04:05Z07:00"; 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 // From healthsdk/interfaces.go
export const safeMTU = 1378; export const safeMTU = 1378;