From 3a81abb2ea184cc328f69fba5ce7d50adc24288d Mon Sep 17 00:00:00 2001 From: Youssef Omar Bouden Date: Fri, 9 Oct 2026 12:33:15 +0100 Subject: [PATCH 1/3] feat(slack): DM the Slack user named by an alert label Routing rules can set notify_email_label to a label key. When that label holds an email, Regen looks the address up in Slack and sends a DM. The DM is sent even if the rule suppresses the incident. Closes #174 --- backend/internal/api/routes.go | 1 + backend/internal/models/routing_rule.go | 11 +- .../services/alert_email_notify_test.go | 138 ++++++++++++++++++ backend/internal/services/alert_service.go | 81 ++++++++++ backend/internal/services/routing_engine.go | 11 ++ .../internal/services/routing_engine_test.go | 32 ++++ docs/alerts/routing.md | 35 +++++ frontend/src/pages/RoutingRulesPage.tsx | 17 +++ 8 files changed, 321 insertions(+), 5 deletions(-) create mode 100644 backend/internal/services/alert_email_notify_test.go diff --git a/backend/internal/api/routes.go b/backend/internal/api/routes.go index 3b43cedc..0f0891e3 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) // 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 999e3066..a8d6e307 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": "email" // DM the Slack user at labels[email] // } 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/services/alert_email_notify_test.go b/backend/internal/services/alert_email_notify_test.go new file mode 100644 index 00000000..ad6e5207 --- /dev/null +++ b/backend/internal/services/alert_email_notify_test.go @@ -0,0 +1,138 @@ +package services + +import ( + "context" + "errors" + "testing" + + "github.com/FluidifyAI/Regen/backend/internal/models" + "github.com/FluidifyAI/Regen/backend/internal/models/webhooks" +) + +type recordingChat struct { + recipients []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(username string, message Message) error { + m.recipients = append(m.recipients, username) + m.texts = append(m.texts, message.Text) + return m.err +} + +func TestAlertService_DMsEmailFromLabelWhenSuppressed(t *testing.T) { + rules := []models.RoutingRule{ + makeRoutingRule(10, models.JSONB{}, models.JSONB{ + "suppress": true, + "notify_email_label": "email", + }), + } + chat := &recordingChat{} + svc := buildAlertServiceWithEscalation(rules, &mockEscalationEngine{}) + svc.SetChatService(chat) + + alert := makeNormalizedAlert("generic", "warning") + alert.Title = "CI failed" + alert.Description = "main" + alert.Labels = map[string]string{"email": "dev@example.com"} + + 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.recipients) != 1 || chat.recipients[0] != "dev@example.com" { + t.Fatalf("recipients = %v, want [dev@example.com]", chat.recipients) + } + if chat.texts[0] != "CI failed\nmain" { + t.Errorf("message = %q", chat.texts[0]) + } +} + +func TestAlertService_SkipsDMWhenLabelIsNotAnEmail(t *testing.T) { + rules := []models.RoutingRule{ + makeRoutingRule(10, models.JSONB{}, models.JSONB{"notify_email_label": "email"}), + } + chat := &recordingChat{} + svc := buildAlertServiceWithEscalation(rules, &mockEscalationEngine{}) + svc.SetChatService(chat) + + alert := makeNormalizedAlert("generic", "warning") + alert.Labels = map[string]string{"email": "not-an-email"} + + if _, err := svc.ProcessNormalizedAlerts(context.Background(), []webhooks.NormalizedAlert{alert}); err != nil { + t.Fatalf("ProcessNormalizedAlerts: %v", err) + } + if len(chat.recipients) != 0 { + t.Fatalf("recipients = %v, want none", chat.recipients) + } +} + +func TestAlertService_SlackErrorDoesNotFailProcessing(t *testing.T) { + rules := []models.RoutingRule{ + makeRoutingRule(10, models.JSONB{}, models.JSONB{"notify_email_label": "email"}), + } + chat := &recordingChat{err: errors.New("slack down")} + svc := buildAlertServiceWithEscalation(rules, &mockEscalationEngine{}) + svc.SetChatService(chat) + + alert := makeNormalizedAlert("generic", "warning") + alert.Labels = map[string]string{"email": "dev@example.com"} + + if _, err := svc.ProcessNormalizedAlerts(context.Background(), []webhooks.NormalizedAlert{alert}); err != nil { + t.Fatalf("ProcessNormalizedAlerts: %v", err) + } + if len(chat.recipients) != 1 { + t.Fatalf("expected one DM attempt, got %d", len(chat.recipients)) + } +} + +func TestAlertService_RepeatAlertDoesNotDMAgain(t *testing.T) { + rules := []models.RoutingRule{ + makeRoutingRule(10, models.JSONB{}, models.JSONB{"notify_email_label": "email"}), + } + chat := &recordingChat{} + alertRepo := &mockAlertRepository{} + svc := NewAlertService(alertRepo, &mockIncidentService{shouldCreate: true}) + svc.SetRoutingEngine(NewRoutingEngine(&mockRoutingRuleRepo{rules: rules})) + svc.SetChatService(chat) + + alert := makeNormalizedAlert("generic", "warning") + alert.Labels = map[string]string{"email": "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.recipients) != 1 { + t.Fatalf("DM count = %d, want 1", len(chat.recipients)) + } +} + +func TestEmailFromLabel(t *testing.T) { + labels := models.JSONB{"email": " dev@example.com ", "name": "dev"} + got, ok := emailFromLabel(labels, "email") + 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 9f7f73ac..0f263be4 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 used to DM a Slack user named by an alert label. + SetChatService(chat ChatService) } // alertService implements AlertService @@ -53,6 +56,7 @@ 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 } // NewAlertService creates a new alert service @@ -92,6 +96,11 @@ func (s *alertService) SetEscalationRepos( s.systemSettingsRepo = systemSettingsRepo } +// SetChatService sets the chat provider used to DM a Slack user named by an alert label. +func (s *alertService) SetChatService(chat ChatService) { + s.chat = chat +} + // ProcessAlertmanagerPayload processes all alerts from an Alertmanager webhook (v0.1 legacy method). // // This method maintains backwards compatibility with existing Prometheus webhook handlers. @@ -221,6 +230,12 @@ func (s *alertService) ProcessNormalizedAlerts(ctx context.Context, alerts []web routingDecision = rd } + // DM the address in the named label before suppress is applied. + // A suppressed CI alert should still reach the person who pushed. + 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 +369,72 @@ func (s *alertService) normalizedAlertToModel(normalized *webhooks.NormalizedAle } } +// notifyLabeledEmail DMs the Slack user whose email is stored in alert.Labels[labelKey]. +// Missing labels, non-email values, and a missing Slack config are skipped. +// 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 { + 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 + } + + text := alert.Title + if alert.Description != "" { + text += "\n" + alert.Description + } + if err := s.chat.SendDirectMessage(email, Message{Text: text}); 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, + "label", labelKey, + "err", err, + ) + } +} + +// emailFromLabel returns the trimmed 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.TrimSpace(email) + if !looksLikeEmail(email) { + return "", false + } + return email, true +} + +// looksLikeEmail rejects values that would fall through to a display-name search. +func looksLikeEmail(s string) bool { + at := strings.LastIndex(s, "@") + if at <= 0 || at == len(s)-1 { + return false + } + if strings.ContainsAny(s, " \t\r\n") { + return false + } + return strings.Contains(s[at+1:], ".") +} + // 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/routing_engine.go b/backend/internal/services/routing_engine.go index f84259ee..e14e5ebd 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 2397a065..5204fd0b 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/docs/alerts/routing.md b/docs/alerts/routing.md index 1b93827d..6d426ad4 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": "email"` | DM the Slack user whose address is in that label. Still sent when the alert is suppressed | ```json { @@ -137,6 +138,40 @@ Use an annotation regex to catch alerts that already self-describe as expected. } ``` +### DM the author of a CI failure + +Match CI webhooks and suppress the incident. Put the author's Slack email in the `email` label. Regen looks that address up with `users.lookupByEmail` and sends a DM. The Slack app needs `users:read.email` and `im:write`. + +```json +{ + "match_criteria": { + "source": ["generic"], + "labels": { "kind": "ci" } + }, + "actions": { + "suppress": true, + "notify_email_label": "email" + } +} +``` + +```json +{ + "alerts": [ + { + "title": "CI failed on main", + "source": "generic", + "severity": "warning", + "external_id": "ci-main-1842", + "labels": { + "kind": "ci", + "email": "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 e3be3c46..8bfe87a4 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 the recipient's Slack email. Sent even when the alert is suppressed.

+ setNotifyEmailLabel(e.target.value)} + placeholder="email" + 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 ?? '…'}`) From 9be876fda2f4bc544d241ccdd5c2190f73fec90e Mon Sep 17 00:00:00 2001 From: Youssef Omar Bouden Date: Tue, 13 Oct 2026 09:29:41 +0100 Subject: [PATCH 2/3] feat(slack): only DM Regen users, resolve by email without fallback The label address must match an active Regen user. When that user has a linked Slack ID the DM goes straight to it. Otherwise the address is resolved with users.lookupByEmail only; the display-name fallback in SendDirectMessage is not used for this path. Signed-off-by: Youssef Omar Bouden --- backend/internal/api/routes.go | 2 +- backend/internal/models/routing_rule.go | 2 +- .../services/alert_email_notify_test.go | 149 +++++++++++++----- backend/internal/services/alert_service.go | 76 ++++++--- backend/internal/services/chat_service.go | 6 + .../internal/services/lazy_slack_service.go | 12 ++ backend/internal/services/slack_service.go | 13 ++ docs/alerts/routing.md | 10 +- frontend/src/pages/RoutingRulesPage.tsx | 4 +- 9 files changed, 199 insertions(+), 75 deletions(-) diff --git a/backend/internal/api/routes.go b/backend/internal/api/routes.go index 0f0891e3..c04ff024 100644 --- a/backend/internal/api/routes.go +++ b/backend/internal/api/routes.go @@ -108,7 +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) + 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 a8d6e307..c2227344 100644 --- a/backend/internal/models/routing_rule.go +++ b/backend/internal/models/routing_rule.go @@ -48,7 +48,7 @@ import ( // "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": "email" // DM the Slack user at labels[email] +// "notify_email_label": "owner" // DM the Regen user at labels[owner] // } type RoutingRule struct { // ID is the unique identifier for this routing rule diff --git a/backend/internal/services/alert_email_notify_test.go b/backend/internal/services/alert_email_notify_test.go index ad6e5207..1a04d2f6 100644 --- a/backend/internal/services/alert_email_notify_test.go +++ b/backend/internal/services/alert_email_notify_test.go @@ -7,12 +7,14 @@ import ( "github.com/FluidifyAI/Regen/backend/internal/models" "github.com/FluidifyAI/Regen/backend/internal/models/webhooks" + "github.com/google/uuid" ) type recordingChat struct { - recipients []string - texts []string - err error + ids []string + emails []string + texts []string + err error } func (m *recordingChat) CreateChannel(string, string) (*Channel, error) { return nil, nil } @@ -23,27 +25,44 @@ func (m *recordingChat) InviteUsers(string, []string) error { return func (m *recordingChat) GetThreadMessages(string, string) ([]string, error) { return nil, nil } -func (m *recordingChat) SendDirectMessage(username string, message Message) error { - m.recipients = append(m.recipients, username) +func (m *recordingChat) SendDirectMessage(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 TestAlertService_DMsEmailFromLabelWhenSuppressed(t *testing.T) { - rules := []models.RoutingRule{ - makeRoutingRule(10, models.JSONB{}, models.JSONB{ - "suppress": true, - "notify_email_label": "email", - }), +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 TestAlertService_DMsEmailFromLabelWhenSuppressed(t *testing.T) { chat := &recordingChat{} - svc := buildAlertServiceWithEscalation(rules, &mockEscalationEngine{}) - svc.SetChatService(chat) + svc := buildAlertServiceWithEscalation(labelDMRule(models.JSONB{ + "suppress": true, + "notify_email_label": "owner", + }), &mockEscalationEngine{}) + svc.SetChatService(chat, labelDMUsers(models.User{Email: "dev@example.com", Active: true})) alert := makeNormalizedAlert("generic", "warning") alert.Title = "CI failed" alert.Description = "main" - alert.Labels = map[string]string{"email": "dev@example.com"} + alert.Labels = map[string]string{"owner": "dev@example.com"} result, err := svc.ProcessNormalizedAlerts(context.Background(), []webhooks.NormalizedAlert{alert}) if err != nil { @@ -52,64 +71,110 @@ func TestAlertService_DMsEmailFromLabelWhenSuppressed(t *testing.T) { if result.IncidentsCreated != 0 { t.Errorf("suppressed alert created %d incidents", result.IncidentsCreated) } - if len(chat.recipients) != 1 || chat.recipients[0] != "dev@example.com" { - t.Fatalf("recipients = %v, want [dev@example.com]", chat.recipients) + 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) != 0 { + t.Errorf("ids = %v, want none", chat.ids) } if chat.texts[0] != "CI failed\nmain" { t.Errorf("message = %q", chat.texts[0]) } } -func TestAlertService_SkipsDMWhenLabelIsNotAnEmail(t *testing.T) { - rules := []models.RoutingRule{ - makeRoutingRule(10, models.JSONB{}, models.JSONB{"notify_email_label": "email"}), +func TestAlertService_UsesStoredSlackIDWithoutLookup(t *testing.T) { + chat := &recordingChat{} + slackID := "U0AJLLY3678" + svc := buildAlertServiceWithEscalation(labelDMRule(models.JSONB{"notify_email_label": "owner"}), &mockEscalationEngine{}) + svc.SetChatService(chat, labelDMUsers(models.User{Email: "dev@example.com", Active: true, SlackUserID: &slackID})) + + alert := makeNormalizedAlert("generic", "warning") + alert.Labels = map[string]string{"owner": "dev@example.com"} + + if _, err := svc.ProcessNormalizedAlerts(context.Background(), []webhooks.NormalizedAlert{alert}); 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) != 0 { + t.Errorf("emails = %v, want none", chat.emails) + } +} + +func TestAlertService_SkipsDMWhenNoRegenUser(t *testing.T) { chat := &recordingChat{} - svc := buildAlertServiceWithEscalation(rules, &mockEscalationEngine{}) - svc.SetChatService(chat) + svc := buildAlertServiceWithEscalation(labelDMRule(models.JSONB{"notify_email_label": "owner"}), &mockEscalationEngine{}) + svc.SetChatService(chat, labelDMUsers()) alert := makeNormalizedAlert("generic", "warning") - alert.Labels = map[string]string{"email": "not-an-email"} + alert.Labels = map[string]string{"owner": "stranger@example.com"} if _, err := svc.ProcessNormalizedAlerts(context.Background(), []webhooks.NormalizedAlert{alert}); err != nil { t.Fatalf("ProcessNormalizedAlerts: %v", err) } - if len(chat.recipients) != 0 { - t.Fatalf("recipients = %v, want none", chat.recipients) + if len(chat.ids)+len(chat.emails) != 0 { + t.Fatalf("DM sent to unknown address: ids=%v emails=%v", chat.ids, chat.emails) } } -func TestAlertService_SlackErrorDoesNotFailProcessing(t *testing.T) { - rules := []models.RoutingRule{ - makeRoutingRule(10, models.JSONB{}, models.JSONB{"notify_email_label": "email"}), +func TestAlertService_SkipsDMWhenUserInactive(t *testing.T) { + chat := &recordingChat{} + svc := buildAlertServiceWithEscalation(labelDMRule(models.JSONB{"notify_email_label": "owner"}), &mockEscalationEngine{}) + svc.SetChatService(chat, labelDMUsers(models.User{Email: "dev@example.com", Active: false})) + + alert := makeNormalizedAlert("generic", "warning") + alert.Labels = map[string]string{"owner": "dev@example.com"} + + if _, err := svc.ProcessNormalizedAlerts(context.Background(), []webhooks.NormalizedAlert{alert}); err != nil { + t.Fatalf("ProcessNormalizedAlerts: %v", err) + } + if len(chat.ids)+len(chat.emails) != 0 { + t.Fatalf("DM sent to inactive user: ids=%v emails=%v", chat.ids, chat.emails) + } +} + +func TestAlertService_SkipsDMWhenLabelIsNotAnEmail(t *testing.T) { + chat := &recordingChat{} + svc := buildAlertServiceWithEscalation(labelDMRule(models.JSONB{"notify_email_label": "owner"}), &mockEscalationEngine{}) + svc.SetChatService(chat, labelDMUsers(models.User{Email: "not-an-email", Active: true})) + + alert := makeNormalizedAlert("generic", "warning") + alert.Labels = map[string]string{"owner": "not-an-email"} + + if _, err := svc.ProcessNormalizedAlerts(context.Background(), []webhooks.NormalizedAlert{alert}); err != nil { + t.Fatalf("ProcessNormalizedAlerts: %v", err) } + if len(chat.ids)+len(chat.emails) != 0 { + t.Fatalf("DM sent for non-email label: ids=%v emails=%v", chat.ids, chat.emails) + } +} + +func TestAlertService_SlackErrorDoesNotFailProcessing(t *testing.T) { chat := &recordingChat{err: errors.New("slack down")} - svc := buildAlertServiceWithEscalation(rules, &mockEscalationEngine{}) - svc.SetChatService(chat) + svc := buildAlertServiceWithEscalation(labelDMRule(models.JSONB{"notify_email_label": "owner"}), &mockEscalationEngine{}) + svc.SetChatService(chat, labelDMUsers(models.User{Email: "dev@example.com", Active: true})) alert := makeNormalizedAlert("generic", "warning") - alert.Labels = map[string]string{"email": "dev@example.com"} + alert.Labels = map[string]string{"owner": "dev@example.com"} if _, err := svc.ProcessNormalizedAlerts(context.Background(), []webhooks.NormalizedAlert{alert}); err != nil { t.Fatalf("ProcessNormalizedAlerts: %v", err) } - if len(chat.recipients) != 1 { - t.Fatalf("expected one DM attempt, got %d", len(chat.recipients)) + if len(chat.emails) != 1 { + t.Fatalf("expected one DM attempt, got %d", len(chat.emails)) } } func TestAlertService_RepeatAlertDoesNotDMAgain(t *testing.T) { - rules := []models.RoutingRule{ - makeRoutingRule(10, models.JSONB{}, models.JSONB{"notify_email_label": "email"}), - } chat := &recordingChat{} alertRepo := &mockAlertRepository{} svc := NewAlertService(alertRepo, &mockIncidentService{shouldCreate: true}) - svc.SetRoutingEngine(NewRoutingEngine(&mockRoutingRuleRepo{rules: rules})) - svc.SetChatService(chat) + 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 := makeNormalizedAlert("generic", "warning") - alert.Labels = map[string]string{"email": "dev@example.com"} + alert.Labels = map[string]string{"owner": "dev@example.com"} if _, err := svc.ProcessNormalizedAlerts(context.Background(), []webhooks.NormalizedAlert{alert}); err != nil { t.Fatalf("first process: %v", err) @@ -118,14 +183,14 @@ func TestAlertService_RepeatAlertDoesNotDMAgain(t *testing.T) { if _, err := svc.ProcessNormalizedAlerts(context.Background(), []webhooks.NormalizedAlert{alert}); err != nil { t.Fatalf("second process: %v", err) } - if len(chat.recipients) != 1 { - t.Fatalf("DM count = %d, want 1", len(chat.recipients)) + if len(chat.emails) != 1 { + t.Fatalf("DM count = %d, want 1", len(chat.emails)) } } func TestEmailFromLabel(t *testing.T) { - labels := models.JSONB{"email": " dev@example.com ", "name": "dev"} - got, ok := emailFromLabel(labels, "email") + 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) } diff --git a/backend/internal/services/alert_service.go b/backend/internal/services/alert_service.go index 0f263be4..df09b332 100644 --- a/backend/internal/services/alert_service.go +++ b/backend/internal/services/alert_service.go @@ -43,8 +43,8 @@ type AlertService interface { // steps in the escalation policy resolution chain. SetEscalationRepos(escalationRepo repository.EscalationPolicyRepository, systemSettingsRepo repository.SystemSettingsRepository) - // SetChatService sets the chat provider used to DM a Slack user named by an alert label. - SetChatService(chat ChatService) + // 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 @@ -57,6 +57,7 @@ type alertService struct { 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 @@ -96,9 +97,10 @@ func (s *alertService) SetEscalationRepos( s.systemSettingsRepo = systemSettingsRepo } -// SetChatService sets the chat provider used to DM a Slack user named by an alert label. -func (s *alertService) SetChatService(chat ChatService) { +// 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). @@ -230,8 +232,8 @@ func (s *alertService) ProcessNormalizedAlerts(ctx context.Context, alerts []web routingDecision = rd } - // DM the address in the named label before suppress is applied. - // A suppressed CI alert should still reach the person who pushed. + // 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) } @@ -369,11 +371,11 @@ func (s *alertService) normalizedAlertToModel(normalized *webhooks.NormalizedAle } } -// notifyLabeledEmail DMs the Slack user whose email is stored in alert.Labels[labelKey]. -// Missing labels, non-email values, and a missing Slack config are skipped. +// 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 { + if s.chat == nil || s.userRepo == nil { return } @@ -386,18 +388,56 @@ func (s *alertService) notifyLabeledEmail(ctx context.Context, alert *models.Ale 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 } - if err := s.chat.SendDirectMessage(email, Message{Text: text}); err != nil { + msg := Message{Text: text} + + if user.SlackUserID != nil && *user.SlackUserID != "" { + err = s.chat.SendDirectMessage(*user.SlackUserID, msg) + } else if byEmail, ok := s.chat.(EmailDirectMessager); ok { + err = byEmail.SendDirectMessageByEmail(user.Email, msg) + } else { + slog.InfoContext(ctx, "chat provider cannot DM by email and user has no slack_user_id", + "alert_id", alert.ID, + "user_id", user.ID, + ) + return + } + 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, - "label", labelKey, + "user_id", user.ID, "err", err, ) } @@ -417,24 +457,12 @@ func emailFromLabel(labels models.JSONB, key string) (string, bool) { return "", false } email = strings.TrimSpace(email) - if !looksLikeEmail(email) { + if !strings.Contains(email, "@") { return "", false } return email, true } -// looksLikeEmail rejects values that would fall through to a display-name search. -func looksLikeEmail(s string) bool { - at := strings.LastIndex(s, "@") - if at <= 0 || at == len(s)-1 { - return false - } - if strings.ContainsAny(s, " \t\r\n") { - return false - } - return strings.Contains(s[at+1:], ".") -} - // 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 04b55e77..eb8a1e0a 100644 --- a/backend/internal/services/chat_service.go +++ b/backend/internal/services/chat_service.go @@ -111,6 +111,12 @@ type ChatService interface { GetThreadMessages(channelID, threadTS string) ([]string, error) } +// EmailDirectMessager is implemented by providers that can DM a user by email +// without falling back to a display-name search. +type EmailDirectMessager interface { + 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 25cc58ec..39c9753c 100644 --- a/backend/internal/services/lazy_slack_service.go +++ b/backend/internal/services/lazy_slack_service.go @@ -119,6 +119,18 @@ func (l *lazySlackService) SendDirectMessage(username string, message Message) e return svc.SendDirectMessage(username, message) } +func (l *lazySlackService) SendDirectMessageByEmail(email string, message Message) error { + svc, err := l.resolve() + if err != nil { + return err + } + byEmail, ok := svc.(EmailDirectMessager) + if !ok { + return fmt.Errorf("slack service does not support DM by email") + } + return byEmail.SendDirectMessageByEmail(email, message) +} + func (l *lazySlackService) GetThreadMessages(channelID, threadTS string) ([]string, error) { svc, err := l.resolve() if err != nil { diff --git a/backend/internal/services/slack_service.go b/backend/internal/services/slack_service.go index 7521c91e..7abf8ac6 100644 --- a/backend/internal/services/slack_service.go +++ b/backend/internal/services/slack_service.go @@ -321,7 +321,20 @@ 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) +} + +// 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 6d426ad4..032dbb93 100644 --- a/docs/alerts/routing.md +++ b/docs/alerts/routing.md @@ -69,7 +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": "email"` | DM the Slack user whose address is in that label. Still sent when the alert is suppressed | +| 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 { @@ -138,9 +138,9 @@ Use an annotation regex to catch alerts that already self-describe as expected. } ``` -### DM the author of a CI failure +### DM the owner of a suppressed alert -Match CI webhooks and suppress the incident. Put the author's Slack email in the `email` label. Regen looks that address up with `users.lookupByEmail` and sends a DM. The Slack app needs `users:read.email` and `im:write`. +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`. ```json { @@ -150,7 +150,7 @@ Match CI webhooks and suppress the incident. Put the author's Slack email in the }, "actions": { "suppress": true, - "notify_email_label": "email" + "notify_email_label": "owner" } } ``` @@ -165,7 +165,7 @@ Match CI webhooks and suppress the incident. Put the author's Slack email in the "external_id": "ci-main-1842", "labels": { "kind": "ci", - "email": "dev@example.com" + "owner": "dev@example.com" } } ] diff --git a/frontend/src/pages/RoutingRulesPage.tsx b/frontend/src/pages/RoutingRulesPage.tsx index 8bfe87a4..3ff89a3c 100644 --- a/frontend/src/pages/RoutingRulesPage.tsx +++ b/frontend/src/pages/RoutingRulesPage.tsx @@ -471,11 +471,11 @@ function RouteBuilderModal({ isOpen, rule, onClose, onSaved }: RouteBuilderModal

Slack DM from label

-

Label whose value is the recipient's Slack email. Sent even when the alert is suppressed.

+

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

setNotifyEmailLabel(e.target.value)} - placeholder="email" + 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" />
From 700db3127e5266bcdea58492258d4cfc906b197c Mon Sep 17 00:00:00 2001 From: Youssef Omar Bouden Date: Thu, 15 Oct 2026 16:22:31 +0100 Subject: [PATCH 3/3] feat(slack): DM stored Slack IDs directly and match label emails case-insensitively A stored Slack ID is sent to the DM method that skips every lookup, so IDs that do not start with U (Enterprise Grid W IDs) no longer fall into the display-name scan. The label email is lower-cased before the user lookup and GetByEmail compares with LOWER() on both sides. The routing doc notes that the webhook sender controls the DM text. Signed-off-by: Youssef Omar Bouden --- .../internal/repository/user_repository.go | 2 +- .../repository/user_repository_email_test.go | 36 ++++++ .../services/alert_email_notify_test.go | 118 ++++++++++-------- backend/internal/services/alert_service.go | 18 +-- backend/internal/services/chat_service.go | 7 +- .../internal/services/lazy_slack_service.go | 24 +++- backend/internal/services/slack_service.go | 5 + docs/alerts/routing.md | 4 +- 8 files changed, 143 insertions(+), 71 deletions(-) create mode 100644 backend/internal/repository/user_repository_email_test.go diff --git a/backend/internal/repository/user_repository.go b/backend/internal/repository/user_repository.go index 11001537..bee8df5f 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 00000000..a62a87d2 --- /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 index 1a04d2f6..aa4eb3a4 100644 --- a/backend/internal/services/alert_email_notify_test.go +++ b/backend/internal/services/alert_email_notify_test.go @@ -13,6 +13,7 @@ import ( type recordingChat struct { ids []string emails []string + names []string texts []string err error } @@ -25,7 +26,12 @@ func (m *recordingChat) InviteUsers(string, []string) error { return func (m *recordingChat) GetThreadMessages(string, string) ([]string, error) { return nil, nil } -func (m *recordingChat) SendDirectMessage(id string, message Message) error { +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 @@ -36,6 +42,8 @@ func (m *recordingChat) SendDirectMessageByEmail(email string, message Message) 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)} } @@ -51,18 +59,28 @@ func labelDMUsers(users ...models.User) *minimalUserRepo { 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 := buildAlertServiceWithEscalation(labelDMRule(models.JSONB{ + svc := labelDMService(chat, labelDMUsers(models.User{Email: "dev@example.com", Active: true}), models.JSONB{ "suppress": true, "notify_email_label": "owner", - }), &mockEscalationEngine{}) - svc.SetChatService(chat, labelDMUsers(models.User{Email: "dev@example.com", Active: true})) + }) - alert := makeNormalizedAlert("generic", "warning") + alert := labelDMAlert("dev@example.com") alert.Title = "CI failed" alert.Description = "main" - alert.Labels = map[string]string{"owner": "dev@example.com"} result, err := svc.ProcessNormalizedAlerts(context.Background(), []webhooks.NormalizedAlert{alert}) if err != nil { @@ -74,8 +92,8 @@ func TestAlertService_DMsEmailFromLabelWhenSuppressed(t *testing.T) { 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) != 0 { - t.Errorf("ids = %v, want none", chat.ids) + 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]) @@ -83,82 +101,76 @@ func TestAlertService_DMsEmailFromLabelWhenSuppressed(t *testing.T) { } func TestAlertService_UsesStoredSlackIDWithoutLookup(t *testing.T) { - chat := &recordingChat{} - slackID := "U0AJLLY3678" - svc := buildAlertServiceWithEscalation(labelDMRule(models.JSONB{"notify_email_label": "owner"}), &mockEscalationEngine{}) - svc.SetChatService(chat, labelDMUsers(models.User{Email: "dev@example.com", Active: true, SlackUserID: &slackID})) + 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"}) - alert := makeNormalizedAlert("generic", "warning") - alert.Labels = map[string]string{"owner": "dev@example.com"} + 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) + } + } +} - if _, err := svc.ProcessNormalizedAlerts(context.Background(), []webhooks.NormalizedAlert{alert}); err != nil { +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.ids) != 1 || chat.ids[0] != slackID { - t.Fatalf("ids = %v, want [%s]", chat.ids, slackID) - } - if len(chat.emails) != 0 { - t.Errorf("emails = %v, want none", chat.emails) + 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 := buildAlertServiceWithEscalation(labelDMRule(models.JSONB{"notify_email_label": "owner"}), &mockEscalationEngine{}) - svc.SetChatService(chat, labelDMUsers()) - - alert := makeNormalizedAlert("generic", "warning") - alert.Labels = map[string]string{"owner": "stranger@example.com"} + svc := labelDMService(chat, labelDMUsers(), models.JSONB{"notify_email_label": "owner"}) - if _, err := svc.ProcessNormalizedAlerts(context.Background(), []webhooks.NormalizedAlert{alert}); err != nil { + if _, err := svc.ProcessNormalizedAlerts(context.Background(), []webhooks.NormalizedAlert{labelDMAlert("stranger@example.com")}); err != nil { t.Fatalf("ProcessNormalizedAlerts: %v", err) } - if len(chat.ids)+len(chat.emails) != 0 { - t.Fatalf("DM sent to unknown address: ids=%v emails=%v", chat.ids, chat.emails) + 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 := buildAlertServiceWithEscalation(labelDMRule(models.JSONB{"notify_email_label": "owner"}), &mockEscalationEngine{}) - svc.SetChatService(chat, labelDMUsers(models.User{Email: "dev@example.com", Active: false})) + svc := labelDMService(chat, labelDMUsers(models.User{Email: "dev@example.com", Active: false}), models.JSONB{"notify_email_label": "owner"}) - alert := makeNormalizedAlert("generic", "warning") - alert.Labels = map[string]string{"owner": "dev@example.com"} - - if _, err := svc.ProcessNormalizedAlerts(context.Background(), []webhooks.NormalizedAlert{alert}); err != nil { + if _, err := svc.ProcessNormalizedAlerts(context.Background(), []webhooks.NormalizedAlert{labelDMAlert("dev@example.com")}); err != nil { t.Fatalf("ProcessNormalizedAlerts: %v", err) } - if len(chat.ids)+len(chat.emails) != 0 { - t.Fatalf("DM sent to inactive user: ids=%v emails=%v", chat.ids, chat.emails) + 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 := buildAlertServiceWithEscalation(labelDMRule(models.JSONB{"notify_email_label": "owner"}), &mockEscalationEngine{}) - svc.SetChatService(chat, labelDMUsers(models.User{Email: "not-an-email", Active: true})) + svc := labelDMService(chat, labelDMUsers(models.User{Email: "not-an-email", Active: true}), models.JSONB{"notify_email_label": "owner"}) - alert := makeNormalizedAlert("generic", "warning") - alert.Labels = map[string]string{"owner": "not-an-email"} - - if _, err := svc.ProcessNormalizedAlerts(context.Background(), []webhooks.NormalizedAlert{alert}); err != nil { + if _, err := svc.ProcessNormalizedAlerts(context.Background(), []webhooks.NormalizedAlert{labelDMAlert("not-an-email")}); err != nil { t.Fatalf("ProcessNormalizedAlerts: %v", err) } - if len(chat.ids)+len(chat.emails) != 0 { - t.Fatalf("DM sent for non-email label: ids=%v emails=%v", chat.ids, chat.emails) + 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 := buildAlertServiceWithEscalation(labelDMRule(models.JSONB{"notify_email_label": "owner"}), &mockEscalationEngine{}) - svc.SetChatService(chat, labelDMUsers(models.User{Email: "dev@example.com", Active: true})) + svc := labelDMService(chat, labelDMUsers(models.User{Email: "dev@example.com", Active: true}), models.JSONB{"notify_email_label": "owner"}) - alert := makeNormalizedAlert("generic", "warning") - alert.Labels = map[string]string{"owner": "dev@example.com"} - - if _, err := svc.ProcessNormalizedAlerts(context.Background(), []webhooks.NormalizedAlert{alert}); err != nil { + if _, err := svc.ProcessNormalizedAlerts(context.Background(), []webhooks.NormalizedAlert{labelDMAlert("dev@example.com")}); err != nil { t.Fatalf("ProcessNormalizedAlerts: %v", err) } if len(chat.emails) != 1 { @@ -173,9 +185,7 @@ func TestAlertService_RepeatAlertDoesNotDMAgain(t *testing.T) { 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 := makeNormalizedAlert("generic", "warning") - alert.Labels = map[string]string{"owner": "dev@example.com"} - + alert := labelDMAlert("dev@example.com") if _, err := svc.ProcessNormalizedAlerts(context.Background(), []webhooks.NormalizedAlert{alert}); err != nil { t.Fatalf("first process: %v", err) } @@ -189,7 +199,7 @@ func TestAlertService_RepeatAlertDoesNotDMAgain(t *testing.T) { } func TestEmailFromLabel(t *testing.T) { - labels := models.JSONB{"owner": " dev@example.com ", "name": "dev"} + 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) diff --git a/backend/internal/services/alert_service.go b/backend/internal/services/alert_service.go index df09b332..3e67abac 100644 --- a/backend/internal/services/alert_service.go +++ b/backend/internal/services/alert_service.go @@ -419,17 +419,19 @@ func (s *alertService) notifyLabeledEmail(ctx context.Context, alert *models.Ale } msg := Message{Text: text} - if user.SlackUserID != nil && *user.SlackUserID != "" { - err = s.chat.SendDirectMessage(*user.SlackUserID, msg) - } else if byEmail, ok := s.chat.(EmailDirectMessager); ok { - err = byEmail.SendDirectMessageByEmail(user.Email, msg) - } else { - slog.InfoContext(ctx, "chat provider cannot DM by email and user has no slack_user_id", + 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) @@ -443,7 +445,7 @@ func (s *alertService) notifyLabeledEmail(ctx context.Context, alert *models.Ale } } -// emailFromLabel returns the trimmed label value when it looks like an email. +// 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 @@ -456,7 +458,7 @@ func emailFromLabel(labels models.JSONB, key string) (string, bool) { if !ok { return "", false } - email = strings.TrimSpace(email) + email = strings.ToLower(strings.TrimSpace(email)) if !strings.Contains(email, "@") { return "", false } diff --git a/backend/internal/services/chat_service.go b/backend/internal/services/chat_service.go index eb8a1e0a..a0b69d04 100644 --- a/backend/internal/services/chat_service.go +++ b/backend/internal/services/chat_service.go @@ -111,9 +111,10 @@ type ChatService interface { GetThreadMessages(channelID, threadTS string) ([]string, error) } -// EmailDirectMessager is implemented by providers that can DM a user by email -// without falling back to a display-name search. -type EmailDirectMessager interface { +// 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 } diff --git a/backend/internal/services/lazy_slack_service.go b/backend/internal/services/lazy_slack_service.go index 39c9753c..6d9b7f44 100644 --- a/backend/internal/services/lazy_slack_service.go +++ b/backend/internal/services/lazy_slack_service.go @@ -119,16 +119,32 @@ 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 { - svc, err := l.resolve() + dm, err := l.resolveUserDM() if err != nil { return err } - byEmail, ok := svc.(EmailDirectMessager) + 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 fmt.Errorf("slack service does not support DM by email") + return nil, fmt.Errorf("slack service does not support user DMs") } - return byEmail.SendDirectMessageByEmail(email, message) + return dm, nil } func (l *lazySlackService) GetThreadMessages(channelID, threadTS string) ([]string, error) { diff --git a/backend/internal/services/slack_service.go b/backend/internal/services/slack_service.go index 7abf8ac6..8f5e5d03 100644 --- a/backend/internal/services/slack_service.go +++ b/backend/internal/services/slack_service.go @@ -324,6 +324,11 @@ func (s *slackService) SendDirectMessage(username string, message Message) error 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 { diff --git a/docs/alerts/routing.md b/docs/alerts/routing.md index 032dbb93..f7a84c6a 100644 --- a/docs/alerts/routing.md +++ b/docs/alerts/routing.md @@ -140,7 +140,9 @@ 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`. +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 {