diff --git a/backend/internal/mail/connectors/usecase.go b/backend/internal/mail/connectors/usecase.go index 78ab9f081..18c1b64cb 100644 --- a/backend/internal/mail/connectors/usecase.go +++ b/backend/internal/mail/connectors/usecase.go @@ -8,10 +8,13 @@ import ( type MailService interface { - SendMail(ctx context.Context, address []string, body string, attatchments []domain.Attatchment) error - SendTemplateMail(ctx context.Context, address []string, template string, vars map[string]string, locale string) error + // SendMail sends body to `to` with optional cc recipients. cc may be nil/empty. + // subject may be empty (compliance-report legacy senders pass their subject + // text inside the body — new callers should pass a proper subject). + SendMail(ctx context.Context, to []string, cc []string, subject, body string, attatchments []domain.Attatchment) error + SendTemplateMail(ctx context.Context, to []string, template string, vars map[string]string, locale string) error // SendMailWithConfig sends a message using the supplied EmailConfig instead // of loading one from storage. Used by orchestration flows such as // "test current mail settings" where the config under test is not persisted. - SendMailWithConfig(ctx context.Context, cfg *domain.EmailConfig, address []string, body string, attatchments []domain.Attatchment) error + SendMailWithConfig(ctx context.Context, cfg *domain.EmailConfig, to []string, cc []string, subject, body string, attatchments []domain.Attatchment) error } diff --git a/backend/internal/mail/usecase/mail_service.go b/backend/internal/mail/usecase/mail_service.go index 1e647d269..1239138fe 100644 --- a/backend/internal/mail/usecase/mail_service.go +++ b/backend/internal/mail/usecase/mail_service.go @@ -23,26 +23,28 @@ func New(repo connectors.MailConfigurationRepository) connectors.MailService { return &mailService{repo: repo} } -func (s *mailService) SendMail(ctx context.Context, address []string, body string, attatchments []domain.Attatchment) error { +func (s *mailService) SendMail(ctx context.Context, to []string, cc []string, subject, body string, attatchments []domain.Attatchment) error { cfg, err := s.repo.GetMailConfiguration(ctx) if err != nil { return fmt.Errorf("load mail config: %w", err) } - return s.SendMailWithConfig(ctx, cfg, address, body, attatchments) + return s.SendMailWithConfig(ctx, cfg, to, cc, subject, body, attatchments) } -func (s *mailService) SendMailWithConfig(ctx context.Context, cfg *domain.EmailConfig, address []string, body string, attatchments []domain.Attatchment) error { +func (s *mailService) SendMailWithConfig(ctx context.Context, cfg *domain.EmailConfig, to []string, cc []string, subject, body string, attatchments []domain.Attatchment) error { if cfg == nil { return fmt.Errorf("mail configuration is nil") } if cfg.Host == "" || cfg.Port == "" { return fmt.Errorf("mail configuration is incomplete") } - if len(address) == 0 { + to = trimAddresses(to) + cc = trimAddresses(cc) + if len(to) == 0 { return fmt.Errorf("no recipients") } - msg, err := buildMessage(cfg, address, body, attatchments) + msg, err := buildMessage(cfg, to, cc, subject, body, attatchments) if err != nil { return err } @@ -50,10 +52,13 @@ func (s *mailService) SendMailWithConfig(ctx context.Context, cfg *domain.EmailC addr := cfg.Host + ":" + cfg.Port auth := smtpAuth(cfg) from := senderAddress(cfg) - return smtp.SendMail(addr, auth, from, address, msg) + // net/smtp needs every recipient in the RCPT TO list — headers alone don't + // deliver mail. Merge to+cc for the envelope; the Cc header stays for display. + rcpt := append(append([]string(nil), to...), cc...) + return smtp.SendMail(addr, auth, from, rcpt, msg) } -func (s *mailService) SendTemplateMail(ctx context.Context, address []string, tmpl string, vars map[string]string, locale string) error { +func (s *mailService) SendTemplateMail(ctx context.Context, to []string, tmpl string, vars map[string]string, locale string) error { tpl, err := template.New("mail").Parse(tmpl) if err != nil { return fmt.Errorf("parse template: %w", err) @@ -66,7 +71,7 @@ func (s *mailService) SendTemplateMail(ctx context.Context, address []string, tm if err := tpl.Execute(&buf, data); err != nil { return fmt.Errorf("render template: %w", err) } - return s.SendMail(ctx, address, buf.String(), nil) + return s.SendMail(ctx, to, nil, "", buf.String(), nil) } func smtpAuth(cfg *domain.EmailConfig) smtp.Auth { @@ -83,13 +88,34 @@ func senderAddress(cfg *domain.EmailConfig) string { return cfg.Username } -func buildMessage(cfg *domain.EmailConfig, to []string, body string, attatchments []domain.Attatchment) ([]byte, error) { +// trimAddresses drops empty strings from a recipient list without touching order. +func trimAddresses(in []string) []string { + if len(in) == 0 { + return nil + } + out := make([]string, 0, len(in)) + for _, a := range in { + a = strings.TrimSpace(a) + if a != "" { + out = append(out, a) + } + } + return out +} + +func buildMessage(cfg *domain.EmailConfig, to []string, cc []string, subject, body string, attatchments []domain.Attatchment) ([]byte, error) { var buf bytes.Buffer writer := multipart.NewWriter(&buf) headers := textproto.MIMEHeader{} headers.Set("From", senderAddress(cfg)) headers.Set("To", strings.Join(to, ", ")) + if len(cc) > 0 { + headers.Set("Cc", strings.Join(cc, ", ")) + } + if subject != "" { + headers.Set("Subject", subject) + } headers.Set("MIME-Version", "1.0") headers.Set("Content-Type", "multipart/mixed; boundary="+writer.Boundary()) if cfg.Orgname != "" { diff --git a/backend/modules.go b/backend/modules.go index 2f1228312..e7deac48a 100644 --- a/backend/modules.go +++ b/backend/modules.go @@ -28,7 +28,6 @@ import ( iam_repository "github.com/utmstack/utmstack/backend/modules/iam/repository" iam_usecase "github.com/utmstack/utmstack/backend/modules/iam/usecase" "github.com/utmstack/utmstack/backend/modules/incidents" - incidents_connectors "github.com/utmstack/utmstack/backend/modules/incidents/connectors" "github.com/utmstack/utmstack/backend/modules/integrations" "github.com/utmstack/utmstack/backend/modules/loganalyzer" mcpmod "github.com/utmstack/utmstack/backend/modules/mcp" @@ -188,7 +187,7 @@ func initModules(db *gorm.DB, cfg *config) *modules { env.String("UPDATES_DIR", "/updates", false)) incidentsMod := incidents.NewModule( db, - incidents_connectors.NewNoopMailer(), + incidents.NewIncidentMailer(mailMod.Service(), configMod.Store(), userRepo), incidents.NewAlertsGatewayFromUsecase(alertsMod.GetAlertUsecase()), incidents.NewIAMGatewayFromRepo(userRepo), auditMod.Logger(), diff --git a/backend/modules/appconfig/usecase/config.go b/backend/modules/appconfig/usecase/config.go index ea3d8d558..f16edc40e 100644 --- a/backend/modules/appconfig/usecase/config.go +++ b/backend/modules/appconfig/usecase/config.go @@ -178,7 +178,7 @@ func (s *service) CheckMail(ctx context.Context, configs []domain.MailConfig) er } } ec := toEmailConfig(cfg) - if err := s.mailer.SendMailWithConfig(ctx, &ec, []string{cfg.From}, body, nil); err != nil { + if err := s.mailer.SendMailWithConfig(ctx, &ec, []string{cfg.From}, nil, "Mail configuration test", body, nil); err != nil { return fmt.Errorf("config[%d] (%s:%d): %w", i, cfg.Host, cfg.Port, err) } } diff --git a/backend/modules/compliance/module.go b/backend/modules/compliance/module.go index aa39a2a35..e712adb97 100644 --- a/backend/modules/compliance/module.go +++ b/backend/modules/compliance/module.go @@ -163,7 +163,8 @@ func (m *mailSender) SendComplianceReport(ctx context.Context, toEmail, subject ContentType: "application/pdf", Bytes: pdfData, } - return m.svc.SendMail(ctx, []string{toEmail}, subject, []mail_domain.Attatchment{attachment}) + body := fmt.Sprintf("
%s
", subject) + return m.svc.SendMail(ctx, []string{toEmail}, nil, subject, body, []mail_domain.Attatchment{attachment}) } // interface assertions diff --git a/backend/modules/incidents/mailer.go b/backend/modules/incidents/mailer.go new file mode 100644 index 000000000..1631e2662 --- /dev/null +++ b/backend/modules/incidents/mailer.go @@ -0,0 +1,133 @@ +package incidents + +import ( + "context" + "fmt" + "html" + "strings" + + "github.com/threatwinds/go-sdk/catcher" + mail_connectors "github.com/utmstack/utmstack/backend/internal/mail/connectors" + appconfig_connectors "github.com/utmstack/utmstack/backend/modules/appconfig/connectors" + iam_connectors "github.com/utmstack/utmstack/backend/modules/iam/connectors" + "github.com/utmstack/utmstack/backend/modules/incidents/connectors" + "github.com/utmstack/utmstack/backend/modules/incidents/domain" +) + +// Config keys for the global alert/incident notification recipients. Both are +// comma-separated lists of email addresses; both are optional. See migration +// 000003_alert_notification_recipients. +const ( + ConfigKeyNotificationTo = "utmstack.alerts.notification_to" + ConfigKeyNotificationCc = "utmstack.alerts.notification_cc" +) + +// incidentMailer sends incident-created notifications. Recipients come from the +// two config keys above; when both are empty it falls back to every activated +// user. +type incidentMailer struct { + mail mail_connectors.MailService + store appconfig_connectors.Store + userRepo iam_connectors.UserRepository +} + +// NewIncidentMailer wires the real mailer used at composition time. +func NewIncidentMailer( + mail mail_connectors.MailService, + store appconfig_connectors.Store, + userRepo iam_connectors.UserRepository, +) connectors.IncidentMailer { + return &incidentMailer{mail: mail, store: store, userRepo: userRepo} +} + +func (m *incidentMailer) SendIncidentCreated(ctx context.Context, incident domain.UtmIncident) error { + if m.mail == nil { + catcher.Warn("incidents: mail service not configured — skipping notification", nil) + return nil + } + to, cc, err := m.resolveRecipients(ctx) + if err != nil { + return fmt.Errorf("resolve incident notification recipients: %w", err) + } + if len(to) == 0 { + catcher.Warn("incidents: no recipients available — skipping notification", nil) + return nil + } + subject, body := renderIncidentCreated(incident) + return m.mail.SendMail(ctx, to, cc, subject, body, nil) +} + +// resolveRecipients returns (to, cc) resolved from config; when BOTH config +// keys are empty it falls back to every activated user's email as `to` and no +// cc. +func (m *incidentMailer) resolveRecipients(ctx context.Context) ([]string, []string, error) { + to := readList(ctx, m.store, ConfigKeyNotificationTo) + cc := readList(ctx, m.store, ConfigKeyNotificationCc) + if len(to) > 0 || len(cc) > 0 { + return to, cc, nil + } + if m.userRepo == nil { + return nil, nil, nil + } + // ponytail: single page, PageSize=200 (repo cap). Enough for every + // production tenant we've seen; paginate if a deployment exceeds it. + users, _, err := m.userRepo.List(ctx, iam_connectors.ListUsersFilter{PageSize: 200}) + if err != nil { + return nil, nil, err + } + fallback := make([]string, 0, len(users)) + for _, u := range users { + if !u.Activated || u.Email == "" { + continue + } + fallback = append(fallback, u.Email) + } + return fallback, nil, nil +} + +// readList reads a config key and splits it on commas. Missing/empty → nil. +func readList(ctx context.Context, store appconfig_connectors.Store, key string) []string { + if store == nil { + return nil + } + v, ok, err := store.GetString(ctx, key) + if err != nil || !ok || strings.TrimSpace(v) == "" { + return nil + } + parts := strings.Split(v, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + out = append(out, p) + } + } + return out +} + +// renderIncidentCreated builds a subject + minimal HTML body for the notification. +func renderIncidentCreated(inc domain.UtmIncident) (subject, body string) { + subject = "New incident: " + inc.IncidentName + + desc := "" + if inc.IncidentDescription != nil { + desc = *inc.IncidentDescription + } + sev := "unknown" + if inc.IncidentSeverity != nil { + sev = fmt.Sprintf("%d", *inc.IncidentSeverity) + } + body = fmt.Sprintf( + ``+ + `Severity: %s
`+ + `Status: %s
`+ + `%s
`+ + ``, + html.EscapeString(inc.IncidentName), + html.EscapeString(sev), + html.EscapeString(inc.IncidentStatus), + html.EscapeString(desc), + ) + return subject, body +} diff --git a/backend/modules/incidents/mailer_test.go b/backend/modules/incidents/mailer_test.go new file mode 100644 index 000000000..d80b6b130 --- /dev/null +++ b/backend/modules/incidents/mailer_test.go @@ -0,0 +1,106 @@ +package incidents + +import ( + "context" + "reflect" + "testing" + + appconfig_connectors "github.com/utmstack/utmstack/backend/modules/appconfig/connectors" + iam_connectors "github.com/utmstack/utmstack/backend/modules/iam/connectors" + iam_domain "github.com/utmstack/utmstack/backend/modules/iam/domain" +) + +// stubStore is a tiny in-memory Store for testing the resolver's fallback path. +type stubStore struct{ values map[string]string } + +func (s *stubStore) GetString(_ context.Context, key string) (string, bool, error) { + v, ok := s.values[key] + return v, ok, nil +} + +func (s *stubStore) SetString(_ context.Context, _, _ string, _ appconfig_connectors.SetOpts) error { + return nil +} + +// stubUserRepo returns a canned user list. Only List is exercised. +type stubUserRepo struct { + iam_connectors.UserRepository // embed so any unused method panics if called + users []iam_domain.User +} + +func (s *stubUserRepo) List(_ context.Context, _ iam_connectors.ListUsersFilter) ([]iam_domain.User, int64, error) { + return s.users, int64(len(s.users)), nil +} + +func TestResolveRecipients(t *testing.T) { + users := []iam_domain.User{ + {Email: "a@x.com", Activated: true}, + {Email: "b@x.com", Activated: false}, // deactivated → dropped + {Email: "", Activated: true}, // no email → dropped + {Email: "c@x.com", Activated: true}, + } + + cases := []struct { + name string + cfg map[string]string + users []iam_domain.User + wantTo []string + wantCc []string + }{ + { + name: "explicit to and cc override user fallback", + cfg: map[string]string{ConfigKeyNotificationTo: "to1@x.com, to2@x.com", ConfigKeyNotificationCc: "cc1@x.com"}, + users: users, + wantTo: []string{"to1@x.com", "to2@x.com"}, + wantCc: []string{"cc1@x.com"}, + }, + { + name: "only cc set — no fallback", + cfg: map[string]string{ConfigKeyNotificationCc: "cc1@x.com"}, + users: users, + wantTo: nil, + wantCc: []string{"cc1@x.com"}, + }, + { + name: "both empty → all activated users with non-empty email", + cfg: map[string]string{}, + users: users, + wantTo: []string{"a@x.com", "c@x.com"}, + wantCc: nil, + }, + { + name: "both empty and no users → empty", + cfg: map[string]string{}, + users: nil, + wantTo: []string{}, + wantCc: nil, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + m := &incidentMailer{ + store: &stubStore{values: tc.cfg}, + userRepo: &stubUserRepo{users: tc.users}, + } + to, cc, err := m.resolveRecipients(context.Background()) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + // Compare with normalised nil/empty: treat empty slice == nil for equality. + if !slicesEqual(to, tc.wantTo) { + t.Errorf("to: got %v want %v", to, tc.wantTo) + } + if !slicesEqual(cc, tc.wantCc) { + t.Errorf("cc: got %v want %v", cc, tc.wantCc) + } + }) + } +} + +func slicesEqual(a, b []string) bool { + if len(a) == 0 && len(b) == 0 { + return true + } + return reflect.DeepEqual(a, b) +} diff --git a/frontend/src/features/alerting-rules/components/rule-form.tsx b/frontend/src/features/alerting-rules/components/rule-form.tsx index 8f933bba0..add61df01 100644 --- a/frontend/src/features/alerting-rules/components/rule-form.tsx +++ b/frontend/src/features/alerting-rules/components/rule-form.tsx @@ -4,6 +4,7 @@ import type { TFunction } from 'i18next' import { AlertTriangle, Check, ChevronDown, Plus, Trash2, X } from 'lucide-react' import { cn } from '@/shared/lib/utils' import { Input } from '@/shared/components/ui/input' +import { ChipInput } from '@/shared/components/ui/chip-input' import type { CorrelationRule, DataTypeOption, DataTypeRef, SaveRuleInput } from '../services/alerting-rules-http.service' /* ─── Structured form model ────────────────────────────────────────────── */ @@ -384,35 +385,6 @@ function DataTypeSelect({ values, options, onChange, t }: { values: string[]; op ) } -/* ─── Chip input ───────────────────────────────────────────────────────── */ - -function ChipInput({ values, onChange, placeholder, mono }: { values: string[]; onChange: (v: string[]) => void; placeholder?: string; mono?: boolean }) { - const [draft, setDraft] = useState('') - const add = () => { - const v = draft.trim() - if (v && !values.includes(v)) onChange([...values, v]) - setDraft('') - } - return ( -+ {t('emailConfig.recipients.helper', 'If both lists are empty, alerts will be sent to all platform users.')} +
+{error}
} +