Skip to content

feat: clicky for notifications#2732

Merged
moshloop merged 24 commits intomainfrom
feat/clicky-notifications
Feb 10, 2026
Merged

feat: clicky for notifications#2732
moshloop merged 24 commits intomainfrom
feat/clicky-notifications

Conversation

@adityathebe
Copy link
Member

@adityathebe adityathebe commented Jan 19, 2026

resolves: #2721

image image

Summary by CodeRabbit

  • New Features

    • Send-history detail now includes rendered markdown, resolved resource info, resource type, and payload-backed content.
    • Introduced a structured, channel-agnostic notification payload for consistent messages across channels.
  • Refactor

    • Unified sending flow to operate on payloads for Slack, email, webhooks and other channels.
    • History now stores both human-readable body and machine payload; responses include rendered markdown.
  • Tests

    • Added SMTP end-to-end email tests and updated notification tests to validate payload contents and grouping.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Jan 19, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Introduce a channel-agnostic NotificationMessagePayload, persist JSON BodyPayload alongside Body in notification history, refactor sending flows (Slack, Shoutrrr, Webhook, SMTP) to use payloads and templating overrides, remove legacy templates, add SMTP E2E test infra, and expose history detail API that renders markdown.

Changes

Cohort / File(s) Summary
Payload & Types
notification/message.go, notification/types.go, notification/context.go
Add NotificationMessagePayload and helpers; add NotificationSendHistoryDetail and RenderBodyMarkdown; add Context.WithBodyPayload to store JSON payloads for history.
Event & History
notification/events.go, db/notifications.go
Build and persist BodyPayload for notification history (buildNotificationHistoryPayload, getNotificationMsg); update DB insert to include BodyPayload.
Senders & Templating
notification/send.go, notification/shoutrrr.go, notification/webhook.go, notification/slack.go
Refactor send flow to payload-driven rendering, add template rendering/override helpers, introduce raw vs payload send paths, adjust shoutrrr/webhook signatures, and remove Slack history JSON marshaling.
API / Controllers
notification/controllers.go, mcp/notifications.go
Add GET /notification/send_history/:id handler and return types that include rendered BodyMarkdown and resolved resource details.
Templates Removed
notification/templates/*
notification/templates/check.failed, .../check.passed, .../component.health, .../config.db.update, .../config.health
Delete legacy channel-specific template files; rendering now driven by payload/formatters.
Tests & SMTP E2E
notification/notification_test.go, notification/shoutrrr_test.go, notification/notification_email_e2e_test.go, notification/suite_test.go
Refactor tests to assert payload content, add in-memory SMTP server and capture backend, and add SMTP E2E tests validating email rendering and delivery.
Playbook & Runner
playbook/actions/notification.go, playbook/runner/runner.go
Switch playbook actions and runner history writes to construct and persist NotificationMessagePayload as BodyPayload.
Silence / Resource Lookup
notification/silence.go
Add "canary" resource lookup path in GetResourceAsMapFromEvent.
Misc / Config
go.mod, .golangci.yml
Minor go.mod replace directive formatting change; add staticcheck exclusion pattern in .golangci.yml.
DB / Send Flow Signatures
notification/send.go, notification/events.go, notification/types.go
Introduce raw NotificationTemplate path for legacy/raw sends; remove direct Body-only usage in some flows and propagate BodyPayload across write and send pathways.

Sequence Diagram(s)

sequenceDiagram
    participant Event as Event Source
    participant Events as notification/events
    participant Builder as notification/message.go
    participant DB as NotificationSendHistory
    participant Sender as Sender (Slack/Shoutrrr/Webhook/SMTP)
    participant Renderer as FormatNotificationMessage

    Event->>Events: Trigger notification event (NotificationEventPayload)
    Events->>Builder: buildNotificationHistoryPayload(payload, celEnv)
    Builder-->>Events: NotificationMessagePayload (BodyPayload)
    Events->>DB: Insert history record with Body and BodyPayload
    Events->>Sender: PrepareAndSendEventNotification(payload / raw path)
    Sender->>Renderer: FormatNotificationMessage(payload, format)
    Renderer-->>Sender: Formatted channel message
    Sender->>Sender: Dispatch via channel (Slack/SMTP/Webhook/etc)
Loading

Possibly related PRs

  • flanksource/mission-control#2617 — touches SMTP/mail and shoutrrr send subsystems; related to SMTP/send refactor and integration.
  • flanksource/mission-control#2762 — modifies webhook delivery and payload wiring; overlaps with webhook/celVariables changes.
  • flanksource/mission-control#2685 — related to notification rendering and shoutrrr/SMTP send flow changes and template handling.

Suggested labels

ready

Suggested reviewers

  • moshloop
🚥 Pre-merge checks | ✅ 4 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'feat: clicky for notifications' is directly related to the main change, which involves integrating the clicky notification builder to generate and store JSON-based notifications.
Linked Issues check ✅ Passed The PR implementation addresses the core objectives from issue #2721: it generates notifications using a structured payload system, stores JSON representation (BodyPayload in NotificationSendHistory), and renders outputs for Slack, Teams, and HTML email via FormatNotificationMessage.
Out of Scope Changes check ✅ Passed All changes are directly related to the notification system refactoring. Template removal, payload-based rendering, and integration across webhook/shoutrrr/slack paths are all necessary for the clicky-based notification system.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/clicky-notifications

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@adityathebe adityathebe force-pushed the feat/clicky-notifications branch 4 times, most recently from 6989bf8 to 14ccef0 Compare January 21, 2026 17:39
@adityathebe
Copy link
Member Author

improve the buttons on email html

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
notification/send.go (1)

87-121: Property templates are not rendered (breaks title/subject).

Properties with template expressions like {{.incident.title}} are passed through unchanged. The test setup shows properties should be templated (see line 94 of notification_test.go), but the current implementation in PrepareAndSendEventNotification sends cn.Properties without rendering them through celEnv. This breaks webhook titles and email subjects.

Add property templating before passing to sendEventNotificationWithMetrics. Since celEnv is available in the function signature, render property values using it—either inline or via a helper function—so template expressions resolve correctly.

🤖 Fix all issues with AI agents
In `@go.mod`:
- Line 53: The go.mod entry for module github.com/flanksource/clicky uses a
non-existent semantic version (v1.14.0); update the dependency in go.mod by
replacing that version with the available pseudo-version
(v0.0.0-20250820111116-a1cda798d4da) or, if your org has a proper tag, confirm
and use the correct semantic tag instead; ensure you run `go mod tidy`
afterwards to validate and regenerate the go.sum entries for the
github.com/flanksource/clicky requirement.

In `@notification/message.go`:
- Around line 74-90: The SlackTitle in the EventCheckPassed handler is using
env.Canary.Name while the Title uses env.Check.Name, causing inconsistent
resource names; update the SlackTitle expression in the case
icapi.EventCheckPassed to use safeName(lo.FromPtr(env.Check).Name) (preserving
the ":large_green_circle: *%s* is _healthy_" format) so both Title and
SlackTitle reference the same env.Check.Name, matching the pattern used in
EventCheckFailed.
♻️ Duplicate comments (1)
notification/notification_test.go (1)

154-157: CI failure: title template still literal in webhook payload.

This assertion currently fails because the title property isn’t being rendered (literal {{...}} reaches the webhook). This is the same root cause flagged in the send-path comment.

🧹 Nitpick comments (2)
notification/controllers.go (1)

100-114: Add a comment explaining the backward compatibility fallback for deprecated Body field.

Static analysis correctly flags lines 112-113 as using the deprecated detail.Body field. The fallback is intentional for backward compatibility with existing records that don't have BodyPayload populated yet, but this intent should be documented.

📝 Suggested documentation
 	if len(detail.BodyPayload) > 0 {
 		var payload NotificationMessagePayload
 		if err := json.Unmarshal(detail.BodyPayload, &payload); err != nil {
 			return api.WriteError(c, ctx.Oops().Wrapf(err, "failed to parse body payload"))
 		}

 		bodyMarkdown, err := FormatNotificationMessage(payload, "markdown")
 		if err != nil {
 			return api.WriteError(c, ctx.Oops().Wrapf(err, "failed to render body payload"))
 		}

 		detail.BodyMarkdown = bodyMarkdown
-	} else if detail.Body != nil {
+	} else if detail.Body != nil { //nolint:staticcheck // Backward compatibility for records created before BodyPayload migration
 		detail.BodyMarkdown = *detail.Body
 	}
notification/events.go (1)

316-335: Use duty.Now() for NotBefore timestamps.

NotBefore is persisted; using duty.Now() keeps DB timestamps consistent with the rest of the codebase.

🔧 Suggested change
-				NotBefore:                 lo.ToPtr(time.Now().Add(*n.WaitFor)),
+				NotBefore:                 lo.ToPtr(duty.Now().Add(*n.WaitFor)),

As per coding guidelines, use duty.Now() for DB timestamps.

@adityathebe adityathebe force-pushed the feat/clicky-notifications branch from 2759c71 to 1092a99 Compare January 23, 2026 13:19
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
playbook/runner/runner.go (1)

230-253: Persist BodyPayload even when no AI headline is set.

BodyPayload can be set without ResourceHealthDescription, but the DB update only runs when the headline is non-empty, so the payload is silently dropped.

🔧 Proposed fix
- if len(sendHistoryUpdate.ResourceHealthDescription) > 0 {
+ if len(sendHistoryUpdate.ResourceHealthDescription) > 0 || len(sendHistoryUpdate.BodyPayload) > 0 {
    if err := ctx.DB().Updates(sendHistoryUpdate).Error; err != nil {
        return ctx.Oops().Wrap(err)
    }
 }

@adityathebe adityathebe force-pushed the feat/clicky-notifications branch from 1092a99 to 8509fb8 Compare January 26, 2026 15:05
@adityathebe adityathebe marked this pull request as draft January 26, 2026 15:05
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@notification/notification_test.go`:
- Around line 154-157: The webhook title is not being rendered because template
properties are not processed with a CEL environment before the payload is
assembled; ensure renderTemplateProperties is called with a non-nil celEnv for
team/custom notification properties prior to building/sending the webhook
payload (the code path that constructs webhookPostdata), e.g., invoke
renderTemplateProperties(celEnv, ...) for the notification's property map in the
webhook preparation function (where webhookPostdata is created) so the
"{{.incident.title}}" template is evaluated and the title/message fields are
rendered before the HTTP send.
♻️ Duplicate comments (1)
notification/message.go (1)

74-90: Inconsistent naming between Title and SlackTitle for check passed event.

On line 75, the Title uses env.Check.Name, but on line 76, the SlackTitle uses env.Canary.Name. This inconsistency could confuse users when the same notification shows different resource names across channels. Compare with EventCheckFailed (lines 58-59) which consistently uses env.Check.Name for both.

🔧 Suggested fix
 	case icapi.EventCheckPassed:
 		msg.Title = fmt.Sprintf("Check %s has passed", safeName(lo.FromPtr(env.Check).Name))
-		msg.SlackTitle = fmt.Sprintf(":large_green_circle: *%s* is _healthy_", safeName(lo.FromPtr(env.Canary).Name))
+		msg.SlackTitle = fmt.Sprintf(":large_green_circle: *%s* is _healthy_", safeName(lo.FromPtr(env.Check).Name))
🧹 Nitpick comments (1)
notification/controllers.go (1)

89-98: Consider defensive handling for empty SourceEvent.

The strings.Split(detail.SourceEvent, ".")[0] call assumes SourceEvent is non-empty. While this should always be true for valid records, an empty string would return an empty ResourceKind. This is likely acceptable but worth noting if data integrity issues occur.

@adityathebe adityathebe force-pushed the feat/clicky-notifications branch from ee58edc to 70570ec Compare January 27, 2026 10:34
@adityathebe adityathebe force-pushed the feat/clicky-notifications branch from 113fbce to d1c94d4 Compare February 4, 2026 16:56
@adityathebe adityathebe marked this pull request as ready for review February 5, 2026 15:51
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In `@notification/message.go`:
- Around line 150-157: Update the responder notification descriptions to show
the responder's name instead of their ID: in the switch cases handling
icapi.EventIncidentResponderAdded and icapi.EventIncidentResponderRemoved (where
msg.Description is set), use lo.FromPtr(env.Responder).Name as the primary value
and fall back to lo.FromPtr(env.Responder).ID if Name is empty or nil so the
text remains user-friendly and robust.

In `@notification/notification_test.go`:
- Around line 58-64: The shared map webhookPostdata must be cleared before
running assertions to avoid stale entries; inside the test setup (e.g., in the
ginkgo.BeforeAll/BeforeEach block where john is created) reset webhookPostdata
by reinitializing or clearing it so that length checks only reflect new
webhooks—refer to the webhookPostdata variable in notification_test.go and
ensure it is emptied before triggering or asserting on any webhook arrival.

In `@notification/template_legacy.go`:
- Around line 28-51: The switch cases currently call templates.ReadFile and
ignore its error (e.g., content, _ := templates.ReadFile(...)) which leads to
silent empty bodies; update each case handling (for events like
api.EventCheckPassed, api.EventCheckFailed, api.EventConfigHealthy,
api.EventConfigCreated, api.EventComponentHealthy, etc.) to check the returned
error from templates.ReadFile and surface it (either return the error, log and
return, or set a clear fallback message) before assigning body = string(content)
so missing/renamed templates fail fast instead of producing empty notifications.
🧹 Nitpick comments (6)
notification/notification_test.go (2)

898-938: Use duty.Now() for DB timestamps in fixtures

CreatedAt uses time.Now(); prefer duty.Now() for database timestamps to keep a consistent time source across the repo. As per coding guidelines, Use duty.Now() instead of time.Now() for database timestamps and soft deletes.

♻️ Suggested update
 import (
 	"encoding/json"
 	"fmt"
 	"time"
 
 	// register event handlers
 
 	"github.com/flanksource/commons/collections"
+	"github.com/flanksource/duty"
 	"github.com/flanksource/duty/models"
 	"github.com/flanksource/duty/query"
 	"github.com/flanksource/duty/types"
@@
-					CreatedAt:  lo.ToPtr(time.Now().Add(-time.Minute * 3)),
+					CreatedAt:  lo.ToPtr(duty.Now().Add(-time.Minute * 3)),
@@
-					CreatedAt:  lo.ToPtr(time.Now().Add(-time.Minute * 2)),
+					CreatedAt:  lo.ToPtr(duty.Now().Add(-time.Minute * 2)),
@@
-					CreatedAt:  lo.ToPtr(time.Now().Add(-time.Minute)),
+					CreatedAt:  lo.ToPtr(duty.Now().Add(-time.Minute)),
@@
-					CreatedAt:  lo.ToPtr(time.Now().Add(-time.Second * 30)),
+					CreatedAt:  lo.ToPtr(duty.Now().Add(-time.Second * 30)),
@@
-				CreatedAt:      time.Now().Add(-time.Second * 10),
+				CreatedAt:      duty.Now().Add(-time.Second * 10),
@@
-				CreatedAt:      time.Now().Add(-time.Second * 10),
+				CreatedAt:      duty.Now().Add(-time.Second * 10),

Also applies to: 1236-1268


115-120: Clean up team/person/component/incident fixtures

AfterAll only deletes the notification; leaving the other rows can bleed into later tests. Consider deleting the fixtures created in BeforeAll/It.

🧹 Suggested cleanup
 ginkgo.AfterAll(func() {
+	if incident != nil {
+		Expect(DefaultContext.DB().Delete(incident).Error).To(BeNil())
+	}
+	if component != nil {
+		Expect(DefaultContext.DB().Delete(component).Error).To(BeNil())
+	}
+	if team != nil {
+		Expect(DefaultContext.DB().Delete(team).Error).To(BeNil())
+	}
+	if john != nil {
+		Expect(DefaultContext.DB().Delete(john).Error).To(BeNil())
+	}
 	err := DefaultContext.DB().Delete(&notif).Error
 	Expect(err).To(BeNil())
 
 	notification.PurgeCache(notif.ID.String())
 })
notification/events.go (1)

317-337: Use duty.Now() for NotBefore timestamps

NotBefore is persisted to the DB; use duty.Now() to align with the repo’s time source. As per coding guidelines, Use duty.Now() instead of time.Now() for database timestamps and soft deletes.

♻️ Suggested change
-				NotBefore:                 lo.ToPtr(time.Now().Add(*n.WaitFor)),
+				NotBefore:                 lo.ToPtr(duty.Now().Add(*n.WaitFor)),
notification/controllers.go (1)

89-98: Consider logging errors from resource resolution instead of silently ignoring them.

Errors from GetResourceAsMapFromEvent and json.Marshal are silently swallowed. While it's acceptable to proceed without resource data, logging these errors would help with debugging issues in production.

Proposed fix
 	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 {
+	if resourceMap, err := GetResourceAsMapFromEvent(ctx, detail.SourceEvent, detail.ResourceID.String()); err != nil {
+		ctx.Logger.Warnf("failed to get resource from event: %v", err)
+	} else if resourceMap != nil {
+		if b, err := json.Marshal(resourceMap); err != nil {
+			ctx.Logger.Warnf("failed to marshal resource map: %v", err)
+		} else {
 			detail.Resource = types.JSON(b)
 		}
 		if resourceType, ok := resourceMap["type"].(string); ok && resourceType != "" {
 			detail.ResourceType = &resourceType
 		}
 	}
notification/shoutrrr.go (1)

239-321: Consider extracting duplicated SMTP handling logic.

The SMTP handling logic in shoutrrrSend (lines 254-311) is nearly identical to shoutrrrSendRaw (lines 115-172). The firstNonEmpty helper function is also duplicated inline. Consider extracting the shared SMTP sending logic into a separate helper function to reduce code duplication and maintenance burden.

Proposed extraction
// sendSMTPEmail extracts common SMTP sending logic
func sendSMTPEmail(ctx *Context, shoutrrrURL string, params *types.Params, title, message string) error {
	parsedURL, err := url.Parse(shoutrrrURL)
	if err != nil {
		return fmt.Errorf("failed to parse shoutrrr URL: %w", err)
	}

	firstNonEmpty := func(params *types.Params, q url.Values, keys ...string) string {
		for _, k := range keys {
			for p := range *params {
				if strings.EqualFold(k, p) {
					return (*params)[p]
				}
			}
			if v := q.Get(k); v != "" {
				return v
			}
		}
		return ""
	}

	query := parsedURL.Query()
	// ... rest of common SMTP logic
}
notification/send.go (1)

329-334: Consider extracting the repeated resourceID extraction pattern.

The same pattern for extracting resourceID from ctx.log is repeated four times across SendRawNotification and SendNotification. This could be extracted into a small helper method on *Context.

Proposed helper
// On Context struct or as a helper:
func (c *Context) resourceIDString() string {
	if c.log != nil && c.log.ResourceID != uuid.Nil {
		return c.log.ResourceID.String()
	}
	return ""
}

Also applies to: 350-354, 391-395, 407-410

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@notification/events.go`:
- Around line 388-414: The re-marshal of Slack blocks in getNotificationMsg
silently ignores json.Marshal errors (when marshaling []any{slackMsg}), leaving
data.Message unchanged; update the block after unmarshaling slackMsg to check
the marshal error and return a wrapped error (e.g., fmt.Errorf("failed to
marshal slack blocks: %w", err)) instead of ignoring it so callers receive the
failure and can handle it appropriately.
🧹 Nitpick comments (4)
notification/events.go (1)

356-386: Consider returning early when payload.GroupID is nil to avoid unnecessary work.

The function correctly handles two modes (template vs. payload-based), but when there's no template and no GroupID, you could skip the grouped resources lookup entirely. Currently, this is handled by the nil check on payload.GroupID, which is fine.

One minor observation: the error message on line 373 references payload.NotificationID but the function receives notification which also has an ID. Both should be the same, but using notification.ID would be more consistent with the pattern used elsewhere.

♻️ Optional: Use consistent ID reference in error message
 	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)
+			return nil, nil, fmt.Errorf("failed to get grouped resources for notification[%s]: %w", notification.ID, err)
 		}
 		env.GroupedResources = groupedResources
 	}
notification/send.go (3)

107-114: Consider propagating the marshal error instead of just logging.

Currently, if json.Marshal fails, the function logs a warning and returns without storing the payload. Depending on the criticality of storing the payload, you may want to return an error or at least ensure callers are aware the payload wasn't stored.

♻️ Optional: Return error from storeNotificationPayload
-func storeNotificationPayload(ctx *Context, payload NotificationMessagePayload) {
+func storeNotificationPayload(ctx *Context, payload NotificationMessagePayload) error {
 	b, err := json.Marshal(payload)
 	if err != nil {
-		ctx.Logger.Warnf("failed to marshal notification payload: %v", err)
-		return
+		return fmt.Errorf("failed to marshal notification payload: %w", err)
 	}
 	ctx.WithBodyPayload(types.JSON(b))
+	return nil
 }

Then handle the error at call sites if payload storage is critical.


116-174: Significant code duplication between PrepareAndSendEventNotification and prepareAndSendRawNotification.

Both functions share nearly identical recipient-handling logic (PersonID, TeamID, CustomService branches). This duplication increases maintenance burden and risk of divergence.

Consider extracting the common recipient resolution logic into a helper, or using a strategy pattern where the only difference is which send function is called.

♻️ Suggested approach to reduce duplication

One option is to create a helper that handles recipient resolution and accepts a send callback:

type sendFunc func(ctx *Context, celEnv *celVariables, payload NotificationEventPayload, notification *NotificationWithSpec, connection string, url string, properties map[string]string) error

func dispatchToRecipients(ctx *Context, payload NotificationEventPayload, celEnv *celVariables, notification *NotificationWithSpec, sender sendFunc) error {
    if payload.PersonID != nil {
        ctx.WithRecipient(RecipientTypePerson, payload.PersonID)
        // ... email lookup ...
        return sender(ctx, celEnv, payload, notification, "", smtpURL, nil)
    }
    // ... similar for TeamID, CustomService ...
}

This would allow PrepareAndSendEventNotification and prepareAndSendRawNotification to share the recipient logic while differing only in which actual send function is used.

Also applies to: 176-220


353-358: Repeated pattern for extracting resourceID from ctx.log.

The same nil-check and extraction pattern for resourceID appears four times in this file. Consider extracting this to a helper method on *Context.

♻️ Optional: Extract resourceID helper
// Add to Context struct or as a method
func (c *Context) ResourceIDString() string {
    if c.log != nil && c.log.ResourceID != uuid.Nil {
        return c.log.ResourceID.String()
    }
    return ""
}

Then replace the repeated blocks:

-	resourceID := ""
-	if ctx.log != nil && ctx.log.ResourceID != uuid.Nil {
-		resourceID = ctx.log.ResourceID.String()
-	}
-	traceLog("NotificationID=%s Resource=[%s] Sent via slack ...", ctx.notificationID, resourceID)
+	traceLog("NotificationID=%s Resource=[%s] Sent via slack ...", ctx.notificationID, ctx.ResourceIDString())

Also applies to: 374-378, 415-419, 431-435

@adityathebe adityathebe force-pushed the feat/clicky-notifications branch from 7e31f79 to 5e8702f Compare February 5, 2026 17:31
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
notification/events.go (1)

317-337: ⚠️ Potential issue | 🟡 Minor

Use duty.Now() instead of time.Now() for the NotBefore timestamp.

NotBefore is persisted to the database in line 337; use duty.Now() to maintain consistency with system-wide timestamp handling.

Suggested fix
-	NotBefore:                 lo.ToPtr(time.Now().Add(*n.WaitFor)),
+	NotBefore:                 lo.ToPtr(duty.Now().Add(*n.WaitFor)),
🤖 Fix all issues with AI agents
In `@notification/send.go`:
- Around line 26-55: DefaultTitleAndBody currently ignores errors from
FormatNotificationMessage and can return an empty body; modify
DefaultTitleAndBody to check the error returned by FormatNotificationMessage and
provide a sensible fallback (e.g., use msgPayload.Message or a concatenation of
msgPayload.Title and msgPayload.Message and/or render groupedResourcesMessage
using msgPayload.GroupedResources) when FormatNotificationMessage returns an
error; keep using BuildNotificationMessagePayload to obtain msgPayload and
respect the slack/non-slack bodyFormat logic (celEnv.Channel) but ensure the
function returns a non-empty body on error and log or surface the formatting
error as appropriate.

In `@notification/suite_test.go`:
- Around line 129-186: In captureSession.Data, don't store the s.to slice
directly into capturedMessage.To because s.to is mutable across sessions;
instead make a new slice copy (e.g., make([]string, len(s.to)) and copy(s.to,
newSlice)) and store that copy in capturedMessage.To before appending to
captureBackend.messages; keep the mutex locking around the append to maintain
thread-safety when writing to captureBackend.messages in the Data method.

In `@notification/webhook.go`:
- Around line 50-52: The grouped-resources footer (groupedResourcesMessage) is
appended after templating so any {{- range .groupedResources }} blocks are left
unrendered; modify the flow so you append groupedResourcesMessage to
data.Message before invoking the templater (or re-run the templater on the
combined message). Specifically, ensure celVars.GroupedResources is available,
then combine data.Message += groupedResourcesMessage prior to calling the
templating function (e.g., Render/Template/templater.RenderTemplate) or call
that templating function again on data.Message after appending so the {{- range
.groupedResources }} block is rendered.
🧹 Nitpick comments (3)
.golangci.yml (1)

22-24: Consider adding a comment explaining why this deprecation is suppressed.

Suppressing SA1019 (deprecated API usage) for WithCredentialsJSON is valid when migration isn't feasible yet, but a brief comment or TODO with a tracking issue would help future maintainers understand the rationale and timeline for addressing the deprecation.

+      # TODO: WithCredentialsJSON is deprecated; tracked in <issue-link>
       - linters:
           - staticcheck
         text: 'SA1019:.*WithCredentialsJSON'
notification/message.go (1)

379-396: Ensure the first rendered action becomes primary.

If the first action is filtered out, the next valid button won’t be marked primary because the index still increments. Consider tracking the count of rendered buttons instead.

♻️ Possible tweak
 func actionsToButtonGroup(actions []NotificationAction) api.ButtonGroup {
 	buttons := make([]api.Button, 0, len(actions))
+	added := 0
 	for i, action := range actions {
 		if action.URL == "" || action.Label == "" {
 			continue
 		}
 		variant := action.Style
-		if variant == "" && i == 0 {
+		if variant == "" && added == 0 {
 			variant = "primary"
 		}
 		buttons = append(buttons, api.Button{
 			Label:   action.Label,
 			Href:    action.URL,
 			Variant: variant,
 		})
+		added++
 	}
 	return api.ButtonGroup{Buttons: buttons}
 }
notification/controllers.go (1)

81-87: Prefer errors.Is for ErrRecordNotFound checks.

Gorm may wrap errors; using errors.Is is more resilient and idiomatic.

🔧 Suggested adjustment
 import (
 	"encoding/json"
+	"errors"
 	"net/http"
 	"strings"
@@
-	if err := ctx.DB().Model(&models.NotificationSendHistory{}).Where("id = ?", id).First(&detail.NotificationSendHistory).Error; err != nil {
-		if err == gorm.ErrRecordNotFound {
+	if err := ctx.DB().Model(&models.NotificationSendHistory{}).Where("id = ?", id).First(&detail.NotificationSendHistory).Error; err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
 			return api.WriteError(c, api.Errorf(api.ENOTFOUND, "notification history %s not found", id))
 		}

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@notification/message.go`:
- Around line 301-307: FormatNotificationMessage is being called with an
unsupported format string "email" which breaks SMTP notifications; find where
the SMTP/shoutrrr send path passes "email" (the callsite that uses
FormatNotificationMessage and sets UseHTML="true" in the shoutrrr/SMTP send
code) and change that format string to "html" so it matches clicky's supported
formats and the existing error message ("failed to format html message") and
UseHTML flag; verify RenderBodyMarkdown remains using "markdown" and that all
calls to FormatNotificationMessage only use supported formats like "html",
"markdown", "json", etc.
🧹 Nitpick comments (4)
notification/shoutrrr.go (1)

112-175: m.Send(conn) error on Line 164 is returned unwrapped, inconsistent with other error paths.

Other error paths in this function (lines 142, 158, 170) wrap errors via ctx.Oops(), but the SMTP Send error is returned bare. This means SMTP send failures won't carry the same structured error context (codes, hints) as shoutrrr failures.

♻️ Suggested fix
-		return m.Send(conn)
+		if err := m.Send(conn); err != nil {
+			return ctx.Oops().Wrapf(err, "error sending email via SMTP")
+		}
+		return nil
notification/send.go (2)

571-593: Template override for Description (lines 586–592) is unreachable in the current call flow.

In PrepareAndSendEventNotification (line 171), a non-empty notification.Template routes to the raw path, so applyTemplateOverrides is only called when Template is empty/whitespace. Consequently, the notification.Template override block here can never fire. This isn't a bug, but it's dead code that could mislead future readers.

♻️ Consider removing the dead branch or adding a comment
 func applyTemplateOverrides(ctx *Context, msgPayload *NotificationMessagePayload, notification *NotificationWithSpec, celEnv *celVariables) {
 	if msgPayload == nil || notification == nil || celEnv == nil {
 		return
 	}
 
 	celEnvMap := celEnv.AsMap(ctx.Context)
 	if strings.TrimSpace(notification.Title) != "" {
 		if rendered, err := renderTemplateString(ctx, celEnvMap, notification.Title); err != nil {
 			ctx.Logger.Warnf("failed to render notification title template %q: %v", notification.Title, err)
 		} else {
 			msgPayload.Title = rendered
 		}
 	}
-
-	if strings.TrimSpace(notification.Template) != "" {
-		if rendered, err := renderTemplateString(ctx, celEnvMap, notification.Template); err != nil {
-			ctx.Logger.Warnf("failed to render notification template %q: %v", notification.Template, err)
-		} else {
-			msgPayload.Description = rendered
-		}
-	}
 }

295-349: Repeated resourceID extraction pattern across both send paths.

The pattern of extracting resourceID from ctx.log appears four times (lines 322–326, 342–346, 383–386, 399–402). A small helper would reduce duplication.

♻️ Optional: extract helper
+func (ctx *Context) resourceIDStr() string {
+	if ctx.log != nil && ctx.log.ResourceID != uuid.Nil {
+		return ctx.log.ResourceID.String()
+	}
+	return ""
+}

Then replace each occurrence:

-	resourceID := ""
-	if ctx.log != nil && ctx.log.ResourceID != uuid.Nil {
-		resourceID = ctx.log.ResourceID.String()
-	}
-	traceLog("NotificationID=%s Resource=[%s] Sent via slack ...", ctx.notificationID, resourceID)
+	traceLog("NotificationID=%s Resource=[%s] Sent via slack ...", ctx.notificationID, ctx.resourceIDStr())

Also applies to: 351-406

notification/message.go (1)

43-169: Comprehensive event-to-payload mapping with good nil-safety.

The BuildNotificationMessagePayload function covers all event types and consistently uses lo.FromPtr, safeName, stringPtr, and coalesceString helpers for nil-safe access. The compactKeyValues call at the end (lines 166-167) cleans up any empty entries.

Minor observation: the default case (line 162-163) only sets msg.Title = payload.EventName, producing a payload with no description, attributes, or actions. If a new event type is added without updating this switch, notifications will be quite sparse. Consider logging a warning for unrecognized events.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In `@notification/send.go`:
- Around line 238-239: The doc comment above sendRawEventNotificationWithMetrics
incorrectly names the function "SendRawEventNotification"; update the comment to
reference the actual function name sendRawEventNotificationWithMetrics (and
adjust capitalization to match exported/unexported status if needed) so the
comment correctly documents sendRawEventNotificationWithMetrics and its purpose
(wrapper around sendRawEventNotification for error handling & metrics).
- Around line 273-274: The top-of-function doc comment currently references the
wrong identifier ("SendEventNotification"); update the comment to reference the
actual function name sendEventNotificationWithMetrics (or reword to a neutral
description) so the doc comment matches the function declaration for
sendEventNotificationWithMetrics; ensure the first word of the comment matches
the exported/unexported naming convention and refers to
sendEventNotificationWithMetrics to keep godoc/lint checks happy.
- Line 619: The warning currently logs both the property key and its value via
ctx.Logger.Warnf, which can leak secrets; change the log to omit or redact the
value and include only the key and the error (e.g., ctx.Logger.Warnf("failed to
render template property %s: %v", key, err)) or otherwise mask the value before
logging; update the call sites where properties/value are passed (the
ctx.Logger.Warnf invocation shown) so only the property name and error are
logged.
🧹 Nitpick comments (2)
notification/send.go (2)

328-332: Repeated resourceID extraction pattern across four locations.

The same pattern for extracting resourceID from ctx.log appears at Lines 328–331, 348–351, 389–392, and 405–408. Consider extracting a small helper to reduce duplication.

Example helper
func (ctx *Context) resourceIDString() string {
	if ctx.log != nil && ctx.log.ResourceID != uuid.Nil {
		return ctx.log.ResourceID.String()
	}
	return ""
}

Also applies to: 348-352, 389-393, 405-409


577-599: The notification.Template branch (Line 592–598) is currently unreachable.

applyTemplateOverrides is only called from PrepareAndSendEventNotification (Line 182), which is gated by notification.Template being empty (Line 177). So the body-override branch on Line 592 will never execute. If this is intentional defensive code for future callers, a brief comment would help; otherwise it can be removed.

@moshloop moshloop force-pushed the feat/clicky-notifications branch from 8afab8f to 1da7566 Compare February 8, 2026 09:22
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In `@notification/message.go`:
- Around line 301-307: The FormatNotificationMessage function uses an
unsupported "email" format for SMTP notifications causing clicky.Format to fail;
update FormatNotificationMessage so any branch that currently treats SMTP/email
as "email" instead maps it to "html" (e.g., normalize format="email" to
format="html" before calling clicky.Format or change the conditional that builds
clicky.FormatOptions to use "html" when UseHTML/SMTP is intended), referencing
the FormatNotificationMessage function and the
payload.ToTextList()/payload.ToSlackTextList() call sites to ensure
clicky.Format receives FormatOptions{Format: "html"} for SMTP/email cases.

In `@notification/send.go`:
- Around line 577-599: The Template rendering branch in applyTemplateOverrides
is dead because callers of applyTemplateOverrides already ensure
strings.TrimSpace(notification.Template) != ""; remove the unreachable block
that checks notification.Template, calls renderTemplateString, and sets
msgPayload.Description (or alternatively keep it but add a clear comment above
the if explaining it is intentionally preserved for future call sites and
referencing that current callers in send.go and events.go already guard
Template) so the function isn’t left with misleading dead code; update
applyTemplateOverrides accordingly (modify the if block that references
notification.Template and msgPayload.Description).

In `@playbook/actions/notification.go`:
- Around line 37-44: SendNotification already delivered the Slack notification
before calling notification.FormatNotificationMessage, so a formatting failure
should not be propagated as an error; instead catch the error from
FormatNotificationMessage in the if service == "slack" block, log the formatting
error using the existing logger, set output.Slack to a degraded/empty value (and
ensure output.Message isn't overwritten into a misleading empty string), and
return the successful output object (or a result with a non-fatal warning flag)
rather than returning the error from FormatNotificationMessage; update the code
around SendNotification and FormatNotificationMessage to implement this
non-fatal handling.
🧹 Nitpick comments (3)
notification/shoutrrr.go (2)

112-175: Centralized SMTP dispatch via mail package looks good overall.

The extraction of SMTP parameters and the mail.New(...).Send(conn) flow is well-structured. Two minor observations:

  1. Line 132: firstNonEmpty(params, query, "to", "ToAddresses", "ToAddress") — if to resolves to an empty string, mail.New will receive an empty recipient list from strings.Split("", ",") producing [""], which may cause a confusing send error downstream rather than an explicit validation error.
  2. Line 164: m.Send(conn) error is returned raw while other errors in this function use ctx.Oops().Wrapf(...). Consider wrapping for consistency.

189-241: Significant duplication with PrepareShoutrrrRaw (lines 49–95).

Both PrepareShoutrrr and PrepareShoutrrrRaw share the same pattern: system SMTP credential resolution → shoutrrr.CreateSenderExtractServiceName → service-based formatting switch. The main difference is that Raw applies CEL templating on a NotificationTemplate while the new version formats a NotificationMessagePayload via FormatNotificationMessage.

Consider extracting the shared setup (credential resolution, sender creation, service extraction) into a private helper to reduce duplication. This is not urgent but would simplify future maintenance.

notification/types.go (1)

23-38: Silent error swallowing in rendering fallback.

RenderBodyMarkdown silently discards errors from both json.Unmarshal and FormatNotificationMessage. The fallback-to-Body behavior is appropriate for a rendering function, but consider adding a trace/debug log on the error paths to aid troubleshooting when payload rendering silently degrades.

@adityathebe adityathebe force-pushed the feat/clicky-notifications branch from 1da7566 to 0b2848b Compare February 9, 2026 05:52
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In `@notification/controllers.go`:
- Around line 74-103: Import the standard library context as an alias (e.g.,
gocontext) and update usages accordingly: add the import gocontext "context",
keep github.com/flanksource/duty/context, then in
GetNotificationSendHistoryDetail replace the type assertion
c.Request().Context().(context.Context) with
c.Request().Context().(gocontext.Context) (and any other occurrences of the
stdlib Context type in this file) so the two context packages are unambiguous.

In `@notification/send.go`:
- Around line 371-393: The Slack branch in SendNotification fails to persist the
sent message because it never calls ctx.WithMessage; update the Slack path
(after formatting the slackMsg and building NotificationTemplate and before
calling SlackSend) to invoke ctx = ctx.WithMessage(data.Message) (or equivalent
usage of ctx.WithMessage) so the message is recorded in history, mirroring the
behavior used in SendRawNotification and the shoutrrrSend fix; ensure you
reference SendNotification, FormatNotificationMessage, NotificationTemplate,
ctx.WithMessage, and SlackSend when making the change.

In `@notification/shoutrrr.go`:
- Around line 243-251: shoutrrrSend never records the message in history because
it doesn't call ctx.WithMessage like shoutrrrSendRaw does; after PrepareShoutrrr
returns (in shoutrrrSend) call ctx.WithMessage(data.Message) (using the returned
data variable) before invoking dispatchNotification so the sent message body is
persisted to notification history.
🧹 Nitpick comments (2)
notification/message.go (1)

441-455: Minor inconsistency in Slack grouped resources formatting.

For the default case, when GroupedResourcesTitle is set (line 451), the format is "*%s:* - %s" with "\n - " join. But the fallback (line 453) uses the same pattern with "Also Failing". This produces *Also Failing:* - item1\n - item2 — the leading space before the dash on the first item creates a slightly different visual than the subsequent items ( - item1 vs \n - item2). This is cosmetic, but the first item has a preceding space while subsequent items start on a new line with -.

notification/shoutrrr.go (1)

97-110: firstNonEmpty does case-insensitive lookup on params but case-sensitive on query values.

url.Values.Get is case-sensitive per Go's net/url package, while the params lookup is case-insensitive. This asymmetry is likely intentional (params keys may vary in casing from shoutrrr), but worth a brief doc note for clarity.

@moshloop moshloop enabled auto-merge (squash) February 10, 2026 06:46
- Apply rendered notification.Title/Template to payload title/description when provided
- Pass celEnv through send pipeline so templates have event data
- Render templated delivery properties before sending
- Update playbook action to pass nil celEnv to SendNotification
Remove SlackTitle and Slack emoji formatting so Slack uses Title directly.
Drop SlackActionsDivider from payload and remove its Slack-only divider logic.
Replace Fields/LabelFields with Attributes and Labels key/value lists, removing label map helpers.
…faults

- Delete notification/templates/ directory with embedded template files

- Delete notification/template_legacy.go

- Move DefaultTitleAndBody() to send.go using clicky-generated content

- Move getNotificationMsg() to events.go
Add linter exclusion for SA1019 deprecation warnings on option.WithCredentialsJSON.

This is a temporary fix - the proper solution requires refactoring to use
credentials files instead of inline JSON certificates. Tracked in GitHub issue.
…dpoints

Add RenderBodyMarkdown helper that renders markdown from body_payload
(clicky) with fallback to legacy body column. Use it in:
- MCP get_notification_detail tool
- GET /notification/silence_preview API
- GET /notification/send_history/:id (refactored to use shared helper)

This fixes backward compatibility for consumers that relied on body
being non-null, which is now NULL for clicky-based notifications.
- Return error when notification name not found in team spec
- Return error when no recipient resolved at all
- Prevents silent success when notifications fail to send
- Add validation after querying person email from DB
- Return error if email is empty or whitespace-only
- Prevents building invalid SMTP URL with empty ToAddresses
- Uses fmt.Errorf for consistency with surrounding code
- Fall back to description when FormatNotificationMessage fails in DefaultTitleAndBody
- Fix doc comments to match function names (sendRawEventNotificationWithMetrics, sendEventNotificationWithMetrics)
- Remove property value from log to avoid leaking sensitive data
- Don't propagate Slack formatting error after notification already sent
Grouped resources are already rendered by clicky via DefaultTitleAndBody.
The old groupedResourcesMessage append was a leftover that caused
double-rendering and unrendered Go template syntax.

DefaultTitleAndBody()
  └─ BuildNotificationMessagePayload()     ← sets msg.GroupedResources
  └─ FormatNotificationMessage()
       └─ payload.ToTextList() / buildTextList()
            └─ if len(p.GroupedResources) > 0 {   ← renders them into output
                 out = append(out, opts.renderGroupedRes(p))
               }
       └─ clicky.Format(...)               ← returns final body with grouped resources already in it
@moshloop moshloop force-pushed the feat/clicky-notifications branch from f79ced2 to 8d23df5 Compare February 10, 2026 06:46
@moshloop moshloop merged commit f019132 into main Feb 10, 2026
8 checks passed
@moshloop moshloop deleted the feat/clicky-notifications branch February 10, 2026 06:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Notifications - Switch to clicky

2 participants