Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
640bfe8
feat: clicky for notifications
adityathebe Jan 23, 2026
544392f
notification: store body payload for blocked/pending and drop legacy …
adityathebe Jan 23, 2026
fcd2642
notification: add send history detail endpoint with rendered markdown
adityathebe Jan 23, 2026
e02e35b
notification: include canary resources in history lookups
adityathebe Jan 23, 2026
10c50a4
fix: lint fixes
adityathebe Jan 23, 2026
22481c2
fix(notification): render history markdown only from payload
adityathebe Jan 26, 2026
e5a60c9
fix(notification): restore template overrides for title and properties
adityathebe Jan 26, 2026
dc90376
refactor(notification): drop slack-specific title formatting
adityathebe Jan 26, 2026
09e9223
refactor(notification): remove slack-only action divider
adityathebe Jan 26, 2026
ab47450
refactor(notification): slim payload attributes and labels
adityathebe Jan 26, 2026
170545c
fix: test
adityathebe Jan 27, 2026
e3e4787
test: add email notification end-to-end coverage
adityathebe Jan 27, 2026
ffbce54
feat(notification): add raw notification delivery via SMTP, webhooks …
adityathebe Feb 5, 2026
d6690db
refactor(notification): remove legacy templates and use clicky for de…
adityathebe Feb 5, 2026
92d0ffe
chore(lint): exclude deprecated WithCredentialsJSON warnings
adityathebe Feb 5, 2026
01a82a6
fix(notification): render body_markdown in MCP and silence preview en…
adityathebe Feb 6, 2026
db2e825
refactor(notification): extract duplicated SMTP/shoutrrr sending logi…
adityathebe Feb 6, 2026
e7b68c2
refactor(notification): deduplicate text list builders
adityathebe Feb 6, 2026
afffd79
refactor(notification): deduplicate recipient send resolution
adityathebe Feb 6, 2026
00738a9
fix(notification): return explicit errors when no recipient resolved
adityathebe Feb 6, 2026
2a9f7df
fix(notification): validate person email is non-empty before sending
adityathebe Feb 6, 2026
be7ce01
fix(notification): address PR review comments
adityathebe Feb 9, 2026
fbd4245
fix(notification): remove duplicate grouped resources append
adityathebe Feb 9, 2026
8d23df5
fix(notification): persist sent body in slack and shoutrrr flows
adityathebe Feb 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ linters:
- linters:
- staticcheck
text: 'QF1008:'
- linters:
- staticcheck
text: 'SA1019:.*WithCredentialsJSON'
formatters:
exclusions:
generated: lax
Expand Down
3 changes: 2 additions & 1 deletion db/notifications.go
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,7 @@ func GetMatchingNotificationSilences(ctx context.Context, resources models.Notif
func SaveUnsentNotificationToHistory(ctx context.Context, sendHistory models.NotificationSendHistory) error {
window := ctx.Properties().Duration("notifications.dedup.window", time.Hour*24)

return ctx.DB().Exec("SELECT * FROM insert_unsent_notification_to_history(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
return ctx.DB().Exec("SELECT * FROM insert_unsent_notification_to_history(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
sendHistory.NotificationID,
sendHistory.SourceEvent,
sendHistory.ResourceID,
Expand All @@ -327,6 +327,7 @@ func SaveUnsentNotificationToHistory(ctx context.Context, sendHistory models.Not
sendHistory.ConnectionID,
sendHistory.PlaybookRunID,
sendHistory.Body,
sendHistory.BodyPayload,
).Error
}

Expand Down
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,8 @@ require (

// replace github.com/flanksource/duty => ../duty

// replace github.com/flanksource/clicky => ../clicky

// replace github.com/flanksource/gomplate/v3 => ../gomplate

// replace github.com/flanksource/commons => ../commons
Expand Down
19 changes: 15 additions & 4 deletions mcp/notifications.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,17 @@ import (
"time"

"github.com/flanksource/duty/models"
"github.com/flanksource/incident-commander/notification"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/timberio/go-datemath"
)

type notificationDetailResponse struct {
models.NotificationSendHistory `json:",inline"`
BodyMarkdown string `json:"body_markdown,omitempty"`
}

const (
toolGetNotificationDetail = "get_notification_detail"
toolGetNotificationsForResource = "get_notifications_for_resource"
Expand Down Expand Up @@ -43,16 +49,21 @@ func getNotificationDetailHandler(goctx gocontext.Context, req mcp.CallToolReque
return mcp.NewToolResultError(err.Error()), nil
}

var notification models.NotificationSendHistory
var history models.NotificationSendHistory
err = ctx.DB().
Table("notification_send_history_summary").
Where("id = ?", sendID).
First(&notification).Error
First(&history).Error
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to get notification: %v", err)), nil
}

return structToMCPResponse(req, []models.NotificationSendHistory{notification}), nil
resp := notificationDetailResponse{
NotificationSendHistory: history,
BodyMarkdown: notification.RenderBodyMarkdown(history),
}

return structToMCPResponse(req, []notificationDetailResponse{resp}), nil
}

func getNotificationsForResourceHandler(goctx gocontext.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
Expand Down Expand Up @@ -122,7 +133,7 @@ func parseDateMath(val string) (time.Time, error) {

func registerNotifications(s *server.MCPServer) {
getNotificationDetailTool := mcp.NewTool(toolGetNotificationDetail,
mcp.WithDescription("Get detailed information about a specific notification including status, body, recipients, resource details, and related entities"),
mcp.WithDescription("Get detailed information about a specific notification including status, body_markdown (rendered body), recipients, resource details, and related entities"),
mcp.WithReadOnlyHintAnnotation(true),
mcp.WithString("send_id",
mcp.Required(),
Expand Down
5 changes: 5 additions & 0 deletions notification/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (

"github.com/flanksource/duty/context"
"github.com/flanksource/duty/models"
"github.com/flanksource/duty/types"
"github.com/google/uuid"
"github.com/samber/lo"
"github.com/samber/oops"
Expand Down Expand Up @@ -55,6 +56,10 @@ func (t *Context) WithMessage(message string) {
t.log.Body = &message
}

func (t *Context) WithBodyPayload(payload types.JSON) {
t.log.BodyPayload = payload
}

func (t *Context) WithRecipient(recipientType RecipientType, id *uuid.UUID) {
t.recipientType = recipientType

Expand Down
43 changes: 41 additions & 2 deletions notification/controllers.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,17 @@ package notification
import (
"encoding/json"
"net/http"
"strings"

"github.com/flanksource/duty/api"
"github.com/flanksource/duty/context"
"github.com/flanksource/duty/models"
"github.com/flanksource/duty/query"
"github.com/flanksource/duty/rbac/policy"
"github.com/flanksource/duty/types"
"github.com/google/uuid"
"github.com/labstack/echo/v4"
"gorm.io/gorm"

"github.com/flanksource/incident-commander/db"
echoSrv "github.com/flanksource/incident-commander/echo"
Expand All @@ -25,6 +28,7 @@ func RegisterRoutes(e *echo.Echo) {
g := e.Group("/notification")

g.POST("/summary", NotificationSendHistorySummary, echoSrv.RLSMiddleware)
g.GET("/send_history/:id", GetNotificationSendHistoryDetail, echoSrv.RLSMiddleware)

g.GET("/events", func(c echo.Context) error {
return c.JSON(http.StatusOK, EventRing.Get())
Expand Down Expand Up @@ -67,9 +71,41 @@ func NotificationSendHistorySummary(c echo.Context) error {
return c.JSON(http.StatusOK, response)
}

func GetNotificationSendHistoryDetail(c echo.Context) error {
ctx := c.Request().Context().(context.Context)
id := c.Param("id")
if _, err := uuid.Parse(id); err != nil {
return api.WriteError(c, api.Errorf(api.EINVALID, "invalid notification history id: %s", id))
}

var detail NotificationSendHistoryDetail
if err := ctx.DB().Model(&models.NotificationSendHistory{}).Where("id = ?", id).First(&detail.NotificationSendHistory).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return api.WriteError(c, api.Errorf(api.ENOTFOUND, "notification history %s not found", id))
}
return api.WriteError(c, ctx.Oops().Wrap(err))
}

resourceKind := strings.Split(detail.SourceEvent, ".")[0]
detail.ResourceKind = resourceKind
if resourceMap, err := GetResourceAsMapFromEvent(ctx, detail.SourceEvent, detail.ResourceID.String()); err == nil && resourceMap != nil {
if b, err := json.Marshal(resourceMap); err == nil {
detail.Resource = types.JSON(b)
}
if resourceType, ok := resourceMap["type"].(string); ok && resourceType != "" {
detail.ResourceType = &resourceType
}
}

detail.BodyMarkdown = RenderBodyMarkdown(detail.NotificationSendHistory)

return c.JSON(http.StatusOK, detail)
}

type NotificationSilencePreviewItem struct {
models.NotificationSendHistory `json:",inline"`
Resource map[string]any `json:"resource"`
BodyMarkdown string `json:"body_markdown,omitempty"`
}

func NotificationSilencePreview(c echo.Context) error {
Expand Down Expand Up @@ -105,10 +141,13 @@ func NotificationSilencePreview(c echo.Context) error {
if err != nil {
return api.WriteError(c, err)
}
resp = append(resp, NotificationSilencePreviewItem{

item := NotificationSilencePreviewItem{
NotificationSendHistory: s,
Resource: rMap,
})
}
item.BodyMarkdown = RenderBodyMarkdown(s)
resp = append(resp, item)
}

return c.JSON(200, resp)
Expand Down
74 changes: 72 additions & 2 deletions notification/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,10 @@ func addNotificationEvent(ctx context.Context, id string, celEnv *celVariables,
if blocker, err := processNotificationConstraints(ctx, *n, payload, celEnv, matchingSilences); err != nil {
return fmt.Errorf("failed to check all conditions for notification[%s]: %w", n.ID, err)
} else if blocker != nil {
body, bodyPayload, err := buildNotificationHistoryPayload(ctx, payload, n, celEnv)
if err != nil {
return err
}
history := models.NotificationSendHistory{
NotificationID: n.ID,
ResourceID: payload.ID,
Expand All @@ -288,7 +292,8 @@ func addNotificationEvent(ctx context.Context, id string, celEnv *celVariables,
PersonID: payload.PersonID,
TeamID: payload.TeamID,
ConnectionID: payload.Connection,
Body: payload.Body,
Body: body,
BodyPayload: bodyPayload,
}

if err := db.SaveUnsentNotificationToHistory(ctx, history); err != nil {
Expand All @@ -309,6 +314,10 @@ func addNotificationEvent(ctx context.Context, id string, celEnv *celVariables,
// Notifications that have waitFor configured go through a waiting stage
// while the rest are sent immediately.
if n.WaitFor != nil {
body, bodyPayload, err := buildNotificationHistoryPayload(ctx, payload, n, celEnv)
if err != nil {
return err
}
pendingHistory := models.NotificationSendHistory{
NotificationID: n.ID,
ResourceID: payload.ID,
Expand All @@ -323,7 +332,8 @@ func addNotificationEvent(ctx context.Context, id string, celEnv *celVariables,
PersonID: payload.PersonID,
ConnectionID: payload.Connection,
TeamID: payload.TeamID,
Body: payload.Body,
Body: body,
BodyPayload: bodyPayload,
}

if err := ctx.DB().Create(&pendingHistory).Error; err != nil {
Expand All @@ -343,6 +353,66 @@ func addNotificationEvent(ctx context.Context, id string, celEnv *celVariables,
return nil
}

func buildNotificationHistoryPayload(ctx context.Context, payload NotificationEventPayload, notification *NotificationWithSpec, celEnv *celVariables) (*string, types.JSON, error) {
if celEnv == nil || notification == nil {
return nil, nil, nil
}

if strings.TrimSpace(notification.Template) != "" {
msg, err := getNotificationMsg(ctx, celEnv, payload, notification)
if err != nil {
return nil, nil, err
}
return lo.ToPtr(msg.Message), nil, nil
}

env := *celEnv
if payload.GroupID != nil {
groupedResources, err := db.GetGroupedResources(ctx, *payload.GroupID, payload.ID.String())
if err != nil {
return nil, nil, fmt.Errorf("failed to get grouped resources for notification[%s]: %w", payload.NotificationID, err)
}
env.GroupedResources = groupedResources
}

msgPayload := BuildNotificationMessagePayload(payload, &env)
applyTemplateOverrides(NewContext(ctx, payload.NotificationID), &msgPayload, notification, &env)
bodyPayload, err := json.Marshal(msgPayload)
if err != nil {
return nil, nil, fmt.Errorf("failed to marshal notification payload: %w", err)
}

return nil, types.JSON(bodyPayload), nil
}

// getNotificationMsg renders a notification message when a custom template is provided.
// It uses clicky-generated defaults for the title if no custom title is specified.
func getNotificationMsg(ctx context.Context, celEnv *celVariables, payload NotificationEventPayload, n *NotificationWithSpec) (*NotificationTemplate, error) {
defaultTitle, defaultBody := DefaultTitleAndBody(payload, celEnv)
data := NotificationTemplate{
Title: lo.CoalesceOrEmpty(n.Title, defaultTitle),
Message: lo.CoalesceOrEmpty(n.Template, defaultBody),
Properties: n.Properties,
}
templater := ctx.NewStructTemplater(celEnv.AsMap(ctx), "", TemplateFuncs)
if err := templater.Walk(&data); err != nil {
return nil, fmt.Errorf("error templating notification: %w", err)
}

if strings.Contains(data.Message, `"blocks"`) {
var slackMsg SlackMsgTemplate
if err := json.Unmarshal([]byte(data.Message), &slackMsg); err != nil {
return nil, fmt.Errorf("failed to unmarshal slack template into blocks: %w", err)
}

if b, err := json.Marshal([]any{slackMsg}); err == nil {
data.Message = string(b)
}
}

return &data, nil
}

type validateResult struct {
BlockedWithStatus string
ParentID *uuid.UUID
Expand Down
Loading