diff --git a/backend/internal/api/routes.go b/backend/internal/api/routes.go index 3b43ced..c04ff02 100644 --- a/backend/internal/api/routes.go +++ b/backend/internal/api/routes.go @@ -108,6 +108,7 @@ func SetupRoutes(router *gin.Engine, db *gorm.DB, cfg *config.Config, appVersion alertSvc.SetRoutingEngine(routingEngine) alertSvc.SetEscalationEngine(escalationEngine) alertSvc.SetEscalationRepos(escalationPolicyRepo, systemSettingsRepo) + alertSvc.SetChatService(chatService, userRepo) // Post-mortem service (v0.7+) commentRepo := repository.NewPostMortemCommentRepository(db) diff --git a/backend/internal/models/routing_rule.go b/backend/internal/models/routing_rule.go index 999e306..c222734 100644 --- a/backend/internal/models/routing_rule.go +++ b/backend/internal/models/routing_rule.go @@ -44,10 +44,11 @@ import ( // actions JSONB schema: // // { -// "create_incident": true, // default action -// "suppress": true, // alert stored, no incident created -// "severity_override": "critical", // override alert severity -// "channel_override": "db-oncall" // override Slack channel name suffix +// "create_incident": true, // default action +// "suppress": true, // alert stored, no incident created +// "severity_override": "critical", // override alert severity +// "channel_override": "db-oncall" // override Slack channel name suffix +// "notify_email_label": "owner" // DM the Regen user at labels[owner] // } type RoutingRule struct { // ID is the unique identifier for this routing rule @@ -71,7 +72,7 @@ type RoutingRule struct { MatchCriteria JSONB `gorm:"type:jsonb;not null;default:'{}'" json:"match_criteria"` // Actions defines what to do when this rule matches (JSONB). - // Supported keys: create_incident, suppress, severity_override, channel_override. + // Supported keys: create_incident, suppress, severity_override, channel_override, notify_email_label. Actions JSONB `gorm:"type:jsonb;not null;default:'{}'" json:"actions"` // CreatedAt is when this rule was created (immutable) diff --git a/backend/internal/repository/user_repository.go b/backend/internal/repository/user_repository.go index 1100153..bee8df5 100644 --- a/backend/internal/repository/user_repository.go +++ b/backend/internal/repository/user_repository.go @@ -66,7 +66,7 @@ func (r *userRepository) GetBySubject(subject string) (*models.User, error) { func (r *userRepository) GetByEmail(email string) (*models.User, error) { var user models.User - err := r.db.Where("email = ?", email).First(&user).Error + err := r.db.Where("LOWER(email) = LOWER(?)", email).First(&user).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, &NotFoundError{Resource: "user", ID: email} } diff --git a/backend/internal/repository/user_repository_email_test.go b/backend/internal/repository/user_repository_email_test.go new file mode 100644 index 0000000..a62a87d --- /dev/null +++ b/backend/internal/repository/user_repository_email_test.go @@ -0,0 +1,36 @@ +package repository_test + +import ( + "testing" + + "github.com/FluidifyAI/Regen/backend/internal/database" + "github.com/FluidifyAI/Regen/backend/internal/models" + "github.com/FluidifyAI/Regen/backend/internal/repository" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestUserRepository_GetByEmail_CaseInsensitive(t *testing.T) { + db := database.SetupTestDB(t) + repo := repository.NewUserRepository(db) + + hash := "x" + require.NoError(t, repo.CreateLocal(&models.User{ + Email: "Dev@Example.com", + Name: "Dev", + AuthSource: "local", + PasswordHash: &hash, + Role: models.UserRoleMember, + Active: true, + })) + + for _, email := range []string{"dev@example.com", "DEV@EXAMPLE.COM", "Dev@Example.com"} { + found, err := repo.GetByEmail(email) + require.NoError(t, err, email) + assert.Equal(t, "Dev@Example.com", found.Email) + } + + _, err := repo.GetByEmail("other@example.com") + var notFound *repository.NotFoundError + assert.ErrorAs(t, err, ¬Found) +} diff --git a/backend/internal/services/alert_email_notify_test.go b/backend/internal/services/alert_email_notify_test.go new file mode 100644 index 0000000..aa4eb3a --- /dev/null +++ b/backend/internal/services/alert_email_notify_test.go @@ -0,0 +1,213 @@ +package services + +import ( + "context" + "errors" + "testing" + + "github.com/FluidifyAI/Regen/backend/internal/models" + "github.com/FluidifyAI/Regen/backend/internal/models/webhooks" + "github.com/google/uuid" +) + +type recordingChat struct { + ids []string + emails []string + names []string + texts []string + err error +} + +func (m *recordingChat) CreateChannel(string, string) (*Channel, error) { return nil, nil } +func (m *recordingChat) PostMessage(string, Message) (string, error) { return "", nil } +func (m *recordingChat) UpdateMessage(string, string, Message) error { return nil } +func (m *recordingChat) ArchiveChannel(string) error { return nil } +func (m *recordingChat) InviteUsers(string, []string) error { return nil } +func (m *recordingChat) GetThreadMessages(string, string) ([]string, error) { + return nil, nil +} +func (m *recordingChat) SendDirectMessage(name string, message Message) error { + m.names = append(m.names, name) + m.texts = append(m.texts, message.Text) + return m.err +} +func (m *recordingChat) SendDirectMessageByUserID(id string, message Message) error { + m.ids = append(m.ids, id) + m.texts = append(m.texts, message.Text) + return m.err +} +func (m *recordingChat) SendDirectMessageByEmail(email string, message Message) error { + m.emails = append(m.emails, email) + m.texts = append(m.texts, message.Text) + return m.err +} + +func (m *recordingChat) total() int { return len(m.ids) + len(m.emails) + len(m.names) } + +func labelDMRule(actions models.JSONB) []models.RoutingRule { + return []models.RoutingRule{makeRoutingRule(10, models.JSONB{}, actions)} +} + +func labelDMUsers(users ...models.User) *minimalUserRepo { + repo := &minimalUserRepo{byEmail: map[string]models.User{}} + for _, u := range users { + if u.ID == uuid.Nil { + u.ID = uuid.New() + } + repo.byEmail[u.Email] = u + } + return repo +} + +func labelDMService(chat *recordingChat, users *minimalUserRepo, actions models.JSONB) AlertService { + svc := buildAlertServiceWithEscalation(labelDMRule(actions), &mockEscalationEngine{}) + svc.SetChatService(chat, users) + return svc +} + +func labelDMAlert(owner string) webhooks.NormalizedAlert { + alert := makeNormalizedAlert("generic", "warning") + alert.Labels = map[string]string{"owner": owner} + return alert +} + +func TestAlertService_DMsEmailFromLabelWhenSuppressed(t *testing.T) { + chat := &recordingChat{} + svc := labelDMService(chat, labelDMUsers(models.User{Email: "dev@example.com", Active: true}), models.JSONB{ + "suppress": true, + "notify_email_label": "owner", + }) + + alert := labelDMAlert("dev@example.com") + alert.Title = "CI failed" + alert.Description = "main" + + result, err := svc.ProcessNormalizedAlerts(context.Background(), []webhooks.NormalizedAlert{alert}) + if err != nil { + t.Fatalf("ProcessNormalizedAlerts: %v", err) + } + if result.IncidentsCreated != 0 { + t.Errorf("suppressed alert created %d incidents", result.IncidentsCreated) + } + if len(chat.emails) != 1 || chat.emails[0] != "dev@example.com" { + t.Fatalf("emails = %v, want [dev@example.com]", chat.emails) + } + if len(chat.ids)+len(chat.names) != 0 { + t.Errorf("ids = %v names = %v, want none", chat.ids, chat.names) + } + if chat.texts[0] != "CI failed\nmain" { + t.Errorf("message = %q", chat.texts[0]) + } +} + +func TestAlertService_UsesStoredSlackIDWithoutLookup(t *testing.T) { + for _, slackID := range []string{"U0AJLLY3678", "W012AB3CDEF"} { + chat := &recordingChat{} + id := slackID + svc := labelDMService(chat, labelDMUsers(models.User{Email: "dev@example.com", Active: true, SlackUserID: &id}), models.JSONB{"notify_email_label": "owner"}) + + if _, err := svc.ProcessNormalizedAlerts(context.Background(), []webhooks.NormalizedAlert{labelDMAlert("dev@example.com")}); err != nil { + t.Fatalf("ProcessNormalizedAlerts: %v", err) + } + if len(chat.ids) != 1 || chat.ids[0] != slackID { + t.Fatalf("ids = %v, want [%s]", chat.ids, slackID) + } + if len(chat.emails)+len(chat.names) != 0 { + t.Errorf("emails = %v names = %v, want none", chat.emails, chat.names) + } + } +} + +func TestAlertService_MatchesLabelEmailCaseInsensitively(t *testing.T) { + chat := &recordingChat{} + svc := labelDMService(chat, labelDMUsers(models.User{Email: "dev@example.com", Active: true}), models.JSONB{"notify_email_label": "owner"}) + + if _, err := svc.ProcessNormalizedAlerts(context.Background(), []webhooks.NormalizedAlert{labelDMAlert(" Dev@Example.com ")}); err != nil { + t.Fatalf("ProcessNormalizedAlerts: %v", err) + } + if len(chat.emails) != 1 || chat.emails[0] != "dev@example.com" { + t.Fatalf("emails = %v, want [dev@example.com]", chat.emails) + } +} + +func TestAlertService_SkipsDMWhenNoRegenUser(t *testing.T) { + chat := &recordingChat{} + svc := labelDMService(chat, labelDMUsers(), models.JSONB{"notify_email_label": "owner"}) + + if _, err := svc.ProcessNormalizedAlerts(context.Background(), []webhooks.NormalizedAlert{labelDMAlert("stranger@example.com")}); err != nil { + t.Fatalf("ProcessNormalizedAlerts: %v", err) + } + if chat.total() != 0 { + t.Fatalf("DM sent to unknown address: ids=%v emails=%v names=%v", chat.ids, chat.emails, chat.names) + } +} + +func TestAlertService_SkipsDMWhenUserInactive(t *testing.T) { + chat := &recordingChat{} + svc := labelDMService(chat, labelDMUsers(models.User{Email: "dev@example.com", Active: false}), models.JSONB{"notify_email_label": "owner"}) + + if _, err := svc.ProcessNormalizedAlerts(context.Background(), []webhooks.NormalizedAlert{labelDMAlert("dev@example.com")}); err != nil { + t.Fatalf("ProcessNormalizedAlerts: %v", err) + } + if chat.total() != 0 { + t.Fatalf("DM sent to inactive user: ids=%v emails=%v names=%v", chat.ids, chat.emails, chat.names) + } +} + +func TestAlertService_SkipsDMWhenLabelIsNotAnEmail(t *testing.T) { + chat := &recordingChat{} + svc := labelDMService(chat, labelDMUsers(models.User{Email: "not-an-email", Active: true}), models.JSONB{"notify_email_label": "owner"}) + + if _, err := svc.ProcessNormalizedAlerts(context.Background(), []webhooks.NormalizedAlert{labelDMAlert("not-an-email")}); err != nil { + t.Fatalf("ProcessNormalizedAlerts: %v", err) + } + if chat.total() != 0 { + t.Fatalf("DM sent for non-email label: ids=%v emails=%v names=%v", chat.ids, chat.emails, chat.names) + } +} + +func TestAlertService_SlackErrorDoesNotFailProcessing(t *testing.T) { + chat := &recordingChat{err: errors.New("slack down")} + svc := labelDMService(chat, labelDMUsers(models.User{Email: "dev@example.com", Active: true}), models.JSONB{"notify_email_label": "owner"}) + + if _, err := svc.ProcessNormalizedAlerts(context.Background(), []webhooks.NormalizedAlert{labelDMAlert("dev@example.com")}); err != nil { + t.Fatalf("ProcessNormalizedAlerts: %v", err) + } + if len(chat.emails) != 1 { + t.Fatalf("expected one DM attempt, got %d", len(chat.emails)) + } +} + +func TestAlertService_RepeatAlertDoesNotDMAgain(t *testing.T) { + chat := &recordingChat{} + alertRepo := &mockAlertRepository{} + svc := NewAlertService(alertRepo, &mockIncidentService{shouldCreate: true}) + svc.SetRoutingEngine(NewRoutingEngine(&mockRoutingRuleRepo{rules: labelDMRule(models.JSONB{"notify_email_label": "owner"})})) + svc.SetChatService(chat, labelDMUsers(models.User{Email: "dev@example.com", Active: true})) + + alert := labelDMAlert("dev@example.com") + if _, err := svc.ProcessNormalizedAlerts(context.Background(), []webhooks.NormalizedAlert{alert}); err != nil { + t.Fatalf("first process: %v", err) + } + alertRepo.existingAlert = &models.Alert{Source: alert.Source, ExternalID: alert.ExternalID} + if _, err := svc.ProcessNormalizedAlerts(context.Background(), []webhooks.NormalizedAlert{alert}); err != nil { + t.Fatalf("second process: %v", err) + } + if len(chat.emails) != 1 { + t.Fatalf("DM count = %d, want 1", len(chat.emails)) + } +} + +func TestEmailFromLabel(t *testing.T) { + labels := models.JSONB{"owner": " Dev@Example.com ", "name": "dev"} + got, ok := emailFromLabel(labels, "owner") + if !ok || got != "dev@example.com" { + t.Fatalf("email = %q ok=%v", got, ok) + } + if _, ok := emailFromLabel(labels, "name"); ok { + t.Fatal("display name should not be treated as an email") + } + if _, ok := emailFromLabel(labels, "missing"); ok { + t.Fatal("missing label should not match") + } +} diff --git a/backend/internal/services/alert_service.go b/backend/internal/services/alert_service.go index 9f7f73a..3e67aba 100644 --- a/backend/internal/services/alert_service.go +++ b/backend/internal/services/alert_service.go @@ -42,6 +42,9 @@ type AlertService interface { // SetEscalationRepos provides repos for the severity-rule and global-fallback // steps in the escalation policy resolution chain. SetEscalationRepos(escalationRepo repository.EscalationPolicyRepository, systemSettingsRepo repository.SystemSettingsRepository) + + // SetChatService sets the chat provider and user lookup used to DM the Regen user named by an alert label. + SetChatService(chat ChatService, users repository.UserRepository) } // alertService implements AlertService @@ -53,6 +56,8 @@ type alertService struct { escalationEngine EscalationEngine // Optional - can be nil if escalation disabled escalationRepo repository.EscalationPolicyRepository // For severity-rule fallback systemSettingsRepo repository.SystemSettingsRepository // For global-fallback policy + chat ChatService // Optional - nil skips label DMs + userRepo repository.UserRepository // Label DMs go only to known users } // NewAlertService creates a new alert service @@ -92,6 +97,12 @@ func (s *alertService) SetEscalationRepos( s.systemSettingsRepo = systemSettingsRepo } +// SetChatService sets the chat provider and user lookup used to DM the Regen user named by an alert label. +func (s *alertService) SetChatService(chat ChatService, users repository.UserRepository) { + s.chat = chat + s.userRepo = users +} + // ProcessAlertmanagerPayload processes all alerts from an Alertmanager webhook (v0.1 legacy method). // // This method maintains backwards compatibility with existing Prometheus webhook handlers. @@ -221,6 +232,12 @@ func (s *alertService) ProcessNormalizedAlerts(ctx context.Context, alerts []web routingDecision = rd } + // DM the user in the named label before suppress is applied, so a + // suppressed alert still reaches its owner. + if routingDecision.NotifyEmailLabel != "" { + s.notifyLabeledEmail(ctx, alert, routingDecision.NotifyEmailLabel) + } + // If routing says suppress, skip incident creation entirely. // Escalation must also not trigger for suppressed alerts. if routingDecision.Suppress { @@ -354,6 +371,100 @@ func (s *alertService) normalizedAlertToModel(normalized *webhooks.NormalizedAle } } +// notifyLabeledEmail DMs the Regen user whose email is stored in alert.Labels[labelKey]. +// Addresses that do not belong to an active Regen user are ignored. +// A Slack API failure is logged and does not fail alert processing. +func (s *alertService) notifyLabeledEmail(ctx context.Context, alert *models.Alert, labelKey string) { + if s.chat == nil || s.userRepo == nil { + return + } + + email, ok := emailFromLabel(alert.Labels, labelKey) + if !ok { + slog.InfoContext(ctx, "notify_email_label set but label is missing or not an email", + "alert_id", alert.ID, + "label", labelKey, + ) + return + } + + user, err := s.userRepo.GetByEmail(email) + if err != nil { + var notFound *repository.NotFoundError + if errors.As(err, ¬Found) { + slog.InfoContext(ctx, "notify_email_label does not match a Regen user", + "alert_id", alert.ID, + "label", labelKey, + ) + } else { + slog.ErrorContext(ctx, "failed to look up user for label DM", + "alert_id", alert.ID, + "label", labelKey, + "err", err, + ) + } + return + } + if !user.Active { + slog.InfoContext(ctx, "notify_email_label matches an inactive user, skipping DM", + "alert_id", alert.ID, + "user_id", user.ID, + ) + return + } + + text := alert.Title + if alert.Description != "" { + text += "\n" + alert.Description + } + msg := Message{Text: text} + + dm, ok := s.chat.(UserDirectMessager) + if !ok { + slog.InfoContext(ctx, "chat provider cannot DM by user ID or email, skipping label DM", + "alert_id", alert.ID, + "user_id", user.ID, + ) + return + } + if user.SlackUserID != nil && *user.SlackUserID != "" { + err = dm.SendDirectMessageByUserID(*user.SlackUserID, msg) + } else { + err = dm.SendDirectMessageByEmail(user.Email, msg) + } + if err != nil { + if errors.Is(err, ErrSlackNotConfigured) { + slog.DebugContext(ctx, "skipping label DM; slack is not configured", "alert_id", alert.ID) + return + } + slog.ErrorContext(ctx, "failed to DM slack user for alert", + "alert_id", alert.ID, + "user_id", user.ID, + "err", err, + ) + } +} + +// emailFromLabel returns the trimmed, lower-cased label value when it looks like an email. +func emailFromLabel(labels models.JSONB, key string) (string, bool) { + if key == "" || labels == nil { + return "", false + } + raw, ok := labels[key] + if !ok { + return "", false + } + email, ok := raw.(string) + if !ok { + return "", false + } + email = strings.ToLower(strings.TrimSpace(email)) + if !strings.Contains(email, "@") { + return "", false + } + return email, true +} + // resolveEscalationPolicy walks the fallback chain to find an escalation policy // for the given alert when the routing rule provides no explicit policy ID. // diff --git a/backend/internal/services/chat_service.go b/backend/internal/services/chat_service.go index 04b55e7..a0b69d0 100644 --- a/backend/internal/services/chat_service.go +++ b/backend/internal/services/chat_service.go @@ -111,6 +111,13 @@ type ChatService interface { GetThreadMessages(channelID, threadTS string) ([]string, error) } +// UserDirectMessager is implemented by providers that can DM a known user ID +// or an email without falling back to a display-name search. +type UserDirectMessager interface { + SendDirectMessageByUserID(userID string, message Message) error + SendDirectMessageByEmail(email string, message Message) error +} + // Channel represents a chat channel with platform-specific details. type Channel struct { // ID is the platform-specific channel identifier diff --git a/backend/internal/services/lazy_slack_service.go b/backend/internal/services/lazy_slack_service.go index 25cc58e..6d9b7f4 100644 --- a/backend/internal/services/lazy_slack_service.go +++ b/backend/internal/services/lazy_slack_service.go @@ -119,6 +119,34 @@ func (l *lazySlackService) SendDirectMessage(username string, message Message) e return svc.SendDirectMessage(username, message) } +func (l *lazySlackService) SendDirectMessageByUserID(userID string, message Message) error { + dm, err := l.resolveUserDM() + if err != nil { + return err + } + return dm.SendDirectMessageByUserID(userID, message) +} + +func (l *lazySlackService) SendDirectMessageByEmail(email string, message Message) error { + dm, err := l.resolveUserDM() + if err != nil { + return err + } + return dm.SendDirectMessageByEmail(email, message) +} + +func (l *lazySlackService) resolveUserDM() (UserDirectMessager, error) { + svc, err := l.resolve() + if err != nil { + return nil, err + } + dm, ok := svc.(UserDirectMessager) + if !ok { + return nil, fmt.Errorf("slack service does not support user DMs") + } + return dm, nil +} + func (l *lazySlackService) GetThreadMessages(channelID, threadTS string) ([]string, error) { svc, err := l.resolve() if err != nil { diff --git a/backend/internal/services/routing_engine.go b/backend/internal/services/routing_engine.go index f84259e..e14e5eb 100644 --- a/backend/internal/services/routing_engine.go +++ b/backend/internal/services/routing_engine.go @@ -31,6 +31,10 @@ type RoutingDecision struct { // Set from the "escalation_policy_id" key in the matching rule's actions JSONB. EscalationPolicyID *uuid.UUID + // NotifyEmailLabel is the alert label whose value is a Slack email to DM. + // Empty means this rule does not DM anyone. The DM is independent of Suppress. + NotifyEmailLabel string + // AIEnabled controls whether AI agents process the resulting incident. // Defaults to true; can be set false via the "ai_enabled" key in the rule's actions JSONB. AIEnabled bool @@ -179,6 +183,7 @@ func (r *routingEngine) EvaluateAlert(alert *models.Alert) (*RoutingDecision, er "suppress", decision.Suppress, "severity_override", decision.SeverityOverride, "channel_override", decision.ChannelOverride, + "notify_email_label", decision.NotifyEmailLabel, ) return decision, nil @@ -388,6 +393,12 @@ func (r *routingEngine) buildDecision(rule *models.RoutingRule) *RoutingDecision } } + if labelKey, ok := actions["notify_email_label"]; ok { + if s, ok := labelKey.(string); ok { + decision.NotifyEmailLabel = strings.TrimSpace(s) + } + } + if policyIDVal, ok := actions["escalation_policy_id"]; ok { if s, ok := policyIDVal.(string); ok { if id, err := uuid.Parse(s); err == nil { diff --git a/backend/internal/services/routing_engine_test.go b/backend/internal/services/routing_engine_test.go index 2397a06..5204fd0 100644 --- a/backend/internal/services/routing_engine_test.go +++ b/backend/internal/services/routing_engine_test.go @@ -559,3 +559,35 @@ func TestRoutingEngine_WildcardStillWorks_Regex(t *testing.T) { t.Error("wildcard * should match any value") } } + +func TestRoutingEngine_NotifyEmailLabel(t *testing.T) { + rules := []models.RoutingRule{ + makeRoutingRule(10, models.JSONB{}, models.JSONB{ + "suppress": true, + "notify_email_label": " email ", + }), + } + engine := NewRoutingEngine(&mockRoutingRuleRepo{rules: rules}) + + decision, err := engine.EvaluateAlert(makeAlert("generic", "warning", nil)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if decision.NotifyEmailLabel != "email" { + t.Errorf("NotifyEmailLabel = %q, want email", decision.NotifyEmailLabel) + } + if !decision.Suppress { + t.Error("expected suppress to stay set alongside notify_email_label") + } +} + +func TestRoutingEngine_NotifyEmailLabel_Absent(t *testing.T) { + engine := NewRoutingEngine(&mockRoutingRuleRepo{rules: nil}) + decision, err := engine.EvaluateAlert(makeAlert("generic", "warning", nil)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if decision.NotifyEmailLabel != "" { + t.Errorf("NotifyEmailLabel = %q, want empty", decision.NotifyEmailLabel) + } +} diff --git a/backend/internal/services/slack_service.go b/backend/internal/services/slack_service.go index 7521c91..8f5e5d0 100644 --- a/backend/internal/services/slack_service.go +++ b/backend/internal/services/slack_service.go @@ -321,7 +321,25 @@ func (s *slackService) SendDirectMessage(username string, message Message) error return fmt.Errorf("failed to look up user %q: %w", username, err) } } + return s.sendDM(username, userID, message) +} + +// SendDirectMessageByUserID DMs a Slack member ID without any lookup. +func (s *slackService) SendDirectMessageByUserID(userID string, message Message) error { + return s.sendDM(userID, userID, message) +} + +// SendDirectMessageByEmail resolves the user with users.lookupByEmail only. +// Unlike SendDirectMessage it never falls back to a display-name search. +func (s *slackService) SendDirectMessageByEmail(email string, message Message) error { + user, err := s.client.GetUserByEmail(email) + if err != nil { + return fmt.Errorf("failed to look up user by email %q: %w", email, err) + } + return s.sendDM(email, user.ID, message) +} +func (s *slackService) sendDM(username, userID string, message Message) error { // Open (or reuse) the DM channel for this user channel, _, _, err := s.client.OpenConversation(&slack.OpenConversationParameters{ Users: []string{userID}, diff --git a/docs/alerts/routing.md b/docs/alerts/routing.md index 1b93827..f7a84c6 100644 --- a/docs/alerts/routing.md +++ b/docs/alerts/routing.md @@ -69,6 +69,7 @@ Regex uses Go's [RE2 syntax](https://pkg.go.dev/regexp/syntax) — no lookaheads | Suppress | `"suppress": true` | Store the alert but create no incident | | Override severity | `"severity_override": "critical"` | Change the alert's severity before incident creation | | Override channel | `"channel_override": "db-oncall"` | Use a different Slack/Teams channel name suffix | +| DM by label | `"notify_email_label": "owner"` | DM the Regen user whose email is in that label. Still sent when the alert is suppressed | ```json { @@ -137,6 +138,42 @@ Use an annotation regex to catch alerts that already self-describe as expected. } ``` +### DM the owner of a suppressed alert + +Put the owner's email in a label and name that label in the rule. The address must belong to an active Regen user; any other address is ignored. When the user has a linked Slack ID, Regen DMs it directly. Otherwise it resolves the email with `users.lookupByEmail`, which needs the `users:read.email` scope. Sending the DM needs `im:write`. Email matching is case-insensitive. + +The DM text is the alert title and description, so whoever can post to the webhook controls what the user receives. Sign the webhook (see [Authentication](sources/generic.md#authentication-optional)) and keep the rule's match criteria narrow. + +```json +{ + "match_criteria": { + "source": ["generic"], + "labels": { "kind": "ci" } + }, + "actions": { + "suppress": true, + "notify_email_label": "owner" + } +} +``` + +```json +{ + "alerts": [ + { + "title": "CI failed on main", + "source": "generic", + "severity": "warning", + "external_id": "ci-main-1842", + "labels": { + "kind": "ci", + "owner": "dev@example.com" + } + } + ] +} +``` + ### Catch-all: create an incident for everything else Set a low-priority rule (high priority number) with an empty `match_criteria` as a fallback. diff --git a/frontend/src/pages/RoutingRulesPage.tsx b/frontend/src/pages/RoutingRulesPage.tsx index e3be3c4..3ff89a3 100644 --- a/frontend/src/pages/RoutingRulesPage.tsx +++ b/frontend/src/pages/RoutingRulesPage.tsx @@ -86,6 +86,7 @@ function RouteBuilderModal({ isOpen, rule, onClose, onSaved }: RouteBuilderModal const [escalate, setEscalate] = useState(false) const [escalationPolicyId, setEpId] = useState('') const [severityOverride, setSevOvr] = useState('') + const [notifyEmailLabel, setNotifyEmailLabel] = useState('') const [policies, setPolicies] = useState([]) const [error, setError] = useState(null) @@ -116,10 +117,12 @@ function RouteBuilderModal({ isOpen, rule, onClose, onSaved }: RouteBuilderModal setEscalate(Boolean(ac.escalation_policy_id)) setEpId(typeof ac.escalation_policy_id === 'string' ? ac.escalation_policy_id : '') setSevOvr(typeof ac.severity_override === 'string' ? ac.severity_override : '') + setNotifyEmailLabel(typeof ac.notify_email_label === 'string' ? ac.notify_email_label : '') } else { setName(''); setEnabled(true); setSources([]); setSeverities([]) setLabelPairs([]); setAnnotPairs([]); setTitle(''); setDesc('') setCreate(true); setSuppress(false); setEscalate(false); setEpId(''); setSevOvr('') + setNotifyEmailLabel('') } setError(null) }, [rule, isOpen]) @@ -164,6 +167,7 @@ function RouteBuilderModal({ isOpen, rule, onClose, onSaved }: RouteBuilderModal if (suppress) actions.suppress = true if (escalate && escalationPolicyId) actions.escalation_policy_id = escalationPolicyId if (severityOverride) actions.severity_override = severityOverride + if (notifyEmailLabel.trim()) actions.notify_email_label = notifyEmailLabel.trim() setSaving(true); setError(null) try { @@ -465,6 +469,16 @@ function RouteBuilderModal({ isOpen, rule, onClose, onSaved }: RouteBuilderModal +
+

Slack DM from label

+

Label whose value is a Regen user's email. Sent even when the alert is suppressed.

+ setNotifyEmailLabel(e.target.value)} + placeholder="owner" + className="w-full px-3 py-2 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-primary focus:border-transparent" + /> +
@@ -522,6 +536,9 @@ function RuleRow({ if (ac.suppress) parts.push('Suppress') if (ac.create_incident) parts.push('Create incident') if (ac.severity_override) parts.push(`→ ${ac.severity_override}`) + if (typeof ac.notify_email_label === 'string' && ac.notify_email_label) { + parts.push(`DM ${ac.notify_email_label}`) + } if (ac.escalation_policy_id) { const policy = policies.find((p) => p.id === ac.escalation_policy_id) parts.push(`Escalate → ${policy?.name ?? '…'}`)