From 79161f3cf3bfd460c6dbed086455fc6ab75e4453 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Mon, 3 Aug 2026 14:38:56 -0600 Subject: [PATCH 1/2] feat[backend](email): added email receiver and maillist configuration --- backend/internal/mail/connectors/usecase.go | 9 +- backend/internal/mail/usecase/mail_service.go | 44 ++++-- backend/modules.go | 3 +- backend/modules/appconfig/usecase/config.go | 2 +- backend/modules/compliance/module.go | 3 +- backend/modules/incidents/mailer.go | 133 ++++++++++++++++++ backend/modules/incidents/mailer_test.go | 106 ++++++++++++++ 7 files changed, 284 insertions(+), 16 deletions(-) create mode 100644 backend/modules/incidents/mailer.go create mode 100644 backend/modules/incidents/mailer_test.go 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( + ``+ + `

%s

`+ + `

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) +} From 2f166a7febd8ebe9c15555247e7e37adf4f59d53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Mon, 3 Aug 2026 14:45:44 -0600 Subject: [PATCH 2/2] feat[frontend](email): added new email configuration --- .../alerting-rules/components/rule-form.tsx | 30 +--------- .../settings/pages/EmailConfigurationPage.tsx | 59 +++++++++++++++++++ .../src/shared/components/ui/chip-input.tsx | 58 ++++++++++++++++++ .../shared/components/ui/email-chip-input.tsx | 27 +++++++++ 4 files changed, 145 insertions(+), 29 deletions(-) create mode 100644 frontend/src/shared/components/ui/chip-input.tsx create mode 100644 frontend/src/shared/components/ui/email-chip-input.tsx 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 ( -
- {values.map((v) => ( - - {v} - - - ))} - setDraft(e.target.value)} - onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); add() } else if (e.key === 'Backspace' && !draft && values.length) onChange(values.slice(0, -1)) }} - onBlur={add} - placeholder={values.length ? '' : placeholder} - className={cn('min-w-[80px] flex-1 bg-transparent px-1 text-xs outline-none', mono && 'font-mono')} - /> -
- ) -} - /* ─── CEL `where` visual builder ───────────────────────────────────────── */ // value kinds: none = no argument, auto = infer (number/bool/string), diff --git a/frontend/src/features/settings/pages/EmailConfigurationPage.tsx b/frontend/src/features/settings/pages/EmailConfigurationPage.tsx index 5668649bd..dedeb1fcf 100644 --- a/frontend/src/features/settings/pages/EmailConfigurationPage.tsx +++ b/frontend/src/features/settings/pages/EmailConfigurationPage.tsx @@ -5,6 +5,7 @@ import { toast } from 'sonner' import { cn } from '@/shared/lib/utils' import { Button } from '@/shared/components/ui/button' import { Input } from '@/shared/components/ui/input' +import { EmailChipInput } from '@/shared/components/ui/email-chip-input' import { configHttpService } from '../services/config-http.service' /* Backend config keys (utm_configuration_parameter), read/written via /config/:key. */ @@ -17,8 +18,13 @@ const K = { baseUrl: 'utmstack.mail.baseUrl', organization: 'utmstack.mail.organization', auth: 'utmstack.mail.properties.mail.smtp.auth', + notificationTo: 'utmstack.alerts.notification_to', + notificationCc: 'utmstack.alerts.notification_cc', } as const +const splitCsv = (s: string): string[] => + s.split(',').map((x) => x.trim()).filter(Boolean) + type Encryption = 'TLS' | 'SSL' | 'NONE' const ENCRYPTIONS: Encryption[] = ['TLS', 'SSL', 'NONE'] @@ -55,6 +61,13 @@ export function EmailConfigurationPage() { const [saving, setSaving] = useState(false) const [test, setTest] = useState<'idle' | 'sending' | 'success' | 'fail'>('idle') + // Alert recipient lists — persisted as comma-separated strings under two config keys. + const [notifyTo, setNotifyTo] = useState([]) + const [notifyCc, setNotifyCc] = useState([]) + const [initialNotifyTo, setInitialNotifyTo] = useState([]) + const [initialNotifyCc, setInitialNotifyCc] = useState([]) + const [savingRecipients, setSavingRecipients] = useState(false) + useEffect(() => { let cancelled = false configHttpService @@ -76,6 +89,12 @@ export function EmailConfigurationPage() { setForm(v) setInitial(v) setPasswordSet(!!m.get(K.password)?.is_set) + const to = splitCsv(val(K.notificationTo)) + const cc = splitCsv(val(K.notificationCc)) + setNotifyTo(to) + setNotifyCc(cc) + setInitialNotifyTo(to) + setInitialNotifyCc(cc) }) .catch(() => { if (!cancelled) toast.error(t('emailConfig.loadError')) @@ -126,6 +145,26 @@ export function EmailConfigurationPage() { } } + const sameList = (a: string[], b: string[]) => a.length === b.length && a.every((x, i) => x === b[i]) + const recipientsDirty = !sameList(notifyTo, initialNotifyTo) || !sameList(notifyCc, initialNotifyCc) + + const saveRecipients = async () => { + setSavingRecipients(true) + try { + const updates: Promise[] = [] + if (!sameList(notifyTo, initialNotifyTo)) updates.push(configHttpService.set(K.notificationTo, { value: notifyTo.join(',') })) + if (!sameList(notifyCc, initialNotifyCc)) updates.push(configHttpService.set(K.notificationCc, { value: notifyCc.join(',') })) + await Promise.all(updates) + setInitialNotifyTo(notifyTo) + setInitialNotifyCc(notifyCc) + toast.success(t('emailConfig.saved')) + } catch { + toast.error(t('emailConfig.saveError')) + } finally { + setSavingRecipients(false) + } + } + const runTest = async () => { if (!form.from) { toast.error(t('emailConfig.test.fromRequired')) @@ -273,6 +312,26 @@ export function EmailConfigurationPage() { {saving ? t('emailConfig.saving') : t('emailConfig.save')} + + {/* Alert recipients — global To/CC applied to every outgoing alert. */} +
+
+ + + + + + +
+

+ {t('emailConfig.recipients.helper', 'If both lists are empty, alerts will be sent to all platform users.')} +

+
+ +
+
)} diff --git a/frontend/src/shared/components/ui/chip-input.tsx b/frontend/src/shared/components/ui/chip-input.tsx new file mode 100644 index 000000000..e3d78e436 --- /dev/null +++ b/frontend/src/shared/components/ui/chip-input.tsx @@ -0,0 +1,58 @@ +import { useState } from 'react' +import { X } from 'lucide-react' +import { cn } from '@/shared/lib/utils' + +export interface ChipInputProps { + values: string[] + onChange: (v: string[]) => void + placeholder?: string + mono?: boolean + /** Optional per-value validator. Returns null when accepted, or an error message when rejected. */ + validate?: (v: string) => string | null + /** Called with the error message when a value is rejected. Cleared on next successful add. */ + onInvalid?: (message: string | null) => void +} + +export function ChipInput({ values, onChange, placeholder, mono, validate, onInvalid }: ChipInputProps) { + const [draft, setDraft] = useState('') + const add = () => { + const v = draft.trim() + if (!v) { + setDraft('') + return + } + if (values.includes(v)) { + setDraft('') + onInvalid?.(null) + return + } + if (validate) { + const err = validate(v) + if (err) { + onInvalid?.(err) + return + } + } + onInvalid?.(null) + onChange([...values, v]) + setDraft('') + } + return ( +
+ {values.map((v) => ( + + {v} + + + ))} + setDraft(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); add() } else if (e.key === 'Backspace' && !draft && values.length) onChange(values.slice(0, -1)) }} + onBlur={add} + placeholder={values.length ? '' : placeholder} + className={cn('min-w-[80px] flex-1 bg-transparent px-1 text-xs outline-none', mono && 'font-mono')} + /> +
+ ) +} diff --git a/frontend/src/shared/components/ui/email-chip-input.tsx b/frontend/src/shared/components/ui/email-chip-input.tsx new file mode 100644 index 000000000..a0b398697 --- /dev/null +++ b/frontend/src/shared/components/ui/email-chip-input.tsx @@ -0,0 +1,27 @@ +import { useState } from 'react' +import { ChipInput } from './chip-input' + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + +export interface EmailChipInputProps { + values: string[] + onChange: (v: string[]) => void + placeholder?: string + invalidMessage?: string +} + +export function EmailChipInput({ values, onChange, placeholder, invalidMessage = 'Invalid email' }: EmailChipInputProps) { + const [error, setError] = useState(null) + return ( +
+ (EMAIL_RE.test(v) ? null : invalidMessage)} + onInvalid={setError} + /> + {error &&

{error}

} +
+ ) +}