Changeset 0.20.0 (#85)
This commit is contained in:
198
server/notifications/email.go
Normal file
198
server/notifications/email.go
Normal file
@@ -0,0 +1,198 @@
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
// SMTPConfig holds SMTP connection settings, loaded from platform_settings.
|
||||
type SMTPConfig struct {
|
||||
Host string `json:"smtp_host"`
|
||||
Port int `json:"smtp_port"`
|
||||
User string `json:"smtp_user"`
|
||||
Password string `json:"smtp_password"` // decrypted at load time
|
||||
From string `json:"smtp_from"`
|
||||
TLS bool `json:"smtp_tls"`
|
||||
}
|
||||
|
||||
// EmailTransport sends notification emails via SMTP.
|
||||
type EmailTransport struct {
|
||||
config SMTPConfig
|
||||
}
|
||||
|
||||
// NewEmailTransport creates an email transport. Returns nil if config is invalid.
|
||||
func NewEmailTransport(cfg SMTPConfig) *EmailTransport {
|
||||
if cfg.Host == "" || cfg.Port == 0 || cfg.From == "" {
|
||||
return nil
|
||||
}
|
||||
return &EmailTransport{config: cfg}
|
||||
}
|
||||
|
||||
// Send delivers an email with both HTML and plaintext bodies.
|
||||
// Uses multipart/alternative MIME for email client compatibility.
|
||||
func (t *EmailTransport) Send(ctx context.Context, to, subject, htmlBody, textBody string) error {
|
||||
if to == "" {
|
||||
return fmt.Errorf("recipient email is empty")
|
||||
}
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", t.config.Host, t.config.Port)
|
||||
boundary := fmt.Sprintf("==boundary_%d==", time.Now().UnixNano())
|
||||
|
||||
// Build MIME message
|
||||
var msg strings.Builder
|
||||
msg.WriteString(fmt.Sprintf("From: %s\r\n", t.config.From))
|
||||
msg.WriteString(fmt.Sprintf("To: %s\r\n", to))
|
||||
msg.WriteString(fmt.Sprintf("Subject: %s\r\n", subject))
|
||||
msg.WriteString("MIME-Version: 1.0\r\n")
|
||||
msg.WriteString(fmt.Sprintf("Content-Type: multipart/alternative; boundary=\"%s\"\r\n", boundary))
|
||||
msg.WriteString("\r\n")
|
||||
|
||||
// Plaintext part
|
||||
msg.WriteString(fmt.Sprintf("--%s\r\n", boundary))
|
||||
msg.WriteString("Content-Type: text/plain; charset=UTF-8\r\n\r\n")
|
||||
msg.WriteString(textBody)
|
||||
msg.WriteString("\r\n")
|
||||
|
||||
// HTML part
|
||||
msg.WriteString(fmt.Sprintf("--%s\r\n", boundary))
|
||||
msg.WriteString("Content-Type: text/html; charset=UTF-8\r\n\r\n")
|
||||
msg.WriteString(htmlBody)
|
||||
msg.WriteString("\r\n")
|
||||
|
||||
msg.WriteString(fmt.Sprintf("--%s--\r\n", boundary))
|
||||
|
||||
// Connect with timeout
|
||||
dialer := net.Dialer{Timeout: 10 * time.Second}
|
||||
conn, err := dialer.DialContext(ctx, "tcp", addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("smtp dial: %w", err)
|
||||
}
|
||||
|
||||
var client *smtp.Client
|
||||
if t.config.TLS {
|
||||
// Implicit TLS (port 465)
|
||||
tlsConn := tls.Client(conn, &tls.Config{ServerName: t.config.Host})
|
||||
client, err = smtp.NewClient(tlsConn, t.config.Host)
|
||||
} else {
|
||||
client, err = smtp.NewClient(conn, t.config.Host)
|
||||
}
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return fmt.Errorf("smtp client: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
// STARTTLS for non-implicit TLS on port 587
|
||||
if !t.config.TLS {
|
||||
if ok, _ := client.Extension("STARTTLS"); ok {
|
||||
if err := client.StartTLS(&tls.Config{ServerName: t.config.Host}); err != nil {
|
||||
return fmt.Errorf("smtp starttls: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Authenticate if credentials provided
|
||||
if t.config.User != "" && t.config.Password != "" {
|
||||
auth := smtp.PlainAuth("", t.config.User, t.config.Password, t.config.Host)
|
||||
if err := client.Auth(auth); err != nil {
|
||||
return fmt.Errorf("smtp auth: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Send
|
||||
if err := client.Mail(t.config.From); err != nil {
|
||||
return fmt.Errorf("smtp MAIL: %w", err)
|
||||
}
|
||||
if err := client.Rcpt(to); err != nil {
|
||||
return fmt.Errorf("smtp RCPT: %w", err)
|
||||
}
|
||||
w, err := client.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("smtp DATA: %w", err)
|
||||
}
|
||||
if _, err := w.Write([]byte(msg.String())); err != nil {
|
||||
return fmt.Errorf("smtp write: %w", err)
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return fmt.Errorf("smtp close data: %w", err)
|
||||
}
|
||||
|
||||
return client.Quit()
|
||||
}
|
||||
|
||||
// SendAsync wraps Send in a goroutine so notification delivery isn't blocked.
|
||||
func (t *EmailTransport) SendAsync(to, subject, htmlBody, textBody string) {
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
if err := t.Send(ctx, to, subject, htmlBody, textBody); err != nil {
|
||||
log.Printf("[email] failed to send to %s: %v", to, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// LoadSMTPConfig reads SMTP settings from global config and returns an SMTPConfig.
|
||||
// Returns nil if email is not enabled or config is missing.
|
||||
// The vault parameter is optional; if provided, it decrypts the SMTP password.
|
||||
func LoadSMTPConfig(gc store.GlobalConfigStore, vault interface{}) (*SMTPConfig, error) {
|
||||
raw, err := gc.Get(context.Background(), "notifications")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("no notification settings: %w", err)
|
||||
}
|
||||
|
||||
// Check if email is enabled
|
||||
enabled, _ := raw["email_enabled"].(bool)
|
||||
if !enabled {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
cfg := SMTPConfig{
|
||||
Host: stringVal(raw, "smtp_host"),
|
||||
Port: intVal(raw, "smtp_port", 587),
|
||||
User: stringVal(raw, "smtp_user"),
|
||||
From: stringVal(raw, "smtp_from"),
|
||||
TLS: boolVal(raw, "smtp_tls"),
|
||||
}
|
||||
|
||||
// Password: try decrypted value first, then raw
|
||||
cfg.Password = stringVal(raw, "smtp_password")
|
||||
|
||||
if cfg.Host == "" {
|
||||
return nil, fmt.Errorf("smtp_host is required")
|
||||
}
|
||||
if cfg.From == "" {
|
||||
return nil, fmt.Errorf("smtp_from is required")
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func stringVal(m map[string]interface{}, key string) string {
|
||||
if v, ok := m[key].(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func intVal(m map[string]interface{}, key string, def int) int {
|
||||
switch v := m[key].(type) {
|
||||
case float64:
|
||||
return int(v)
|
||||
case int:
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func boolVal(m map[string]interface{}, key string) bool {
|
||||
v, _ := m[key].(bool)
|
||||
return v
|
||||
}
|
||||
203
server/notifications/service.go
Normal file
203
server/notifications/service.go
Normal file
@@ -0,0 +1,203 @@
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/events"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
// ── Package-level singleton ─────────────────
|
||||
// Allows notification sources (KB ingester, group handler, etc.)
|
||||
// to access the service without threading it through constructors.
|
||||
|
||||
var defaultService *Service
|
||||
|
||||
// SetDefault registers the global notification service instance.
|
||||
// Called once at startup from main.go.
|
||||
func SetDefault(svc *Service) {
|
||||
defaultService = svc
|
||||
}
|
||||
|
||||
// Default returns the global notification service instance, or nil if
|
||||
// not configured (e.g. in unmanaged mode without a database).
|
||||
func Default() *Service {
|
||||
return defaultService
|
||||
}
|
||||
|
||||
// Service handles notification creation, persistence, and real-time delivery.
|
||||
// All notification sources should go through this service — never insert directly.
|
||||
type Service struct {
|
||||
store store.NotificationStore
|
||||
prefStore store.NotificationPreferenceStore
|
||||
userStore store.UserStore
|
||||
hub *events.Hub
|
||||
|
||||
// Email transport (nil = email disabled)
|
||||
email *EmailTransport
|
||||
instanceName string
|
||||
|
||||
// Retention cleanup
|
||||
retentionDays int
|
||||
stopCleanup chan struct{}
|
||||
cleanupWg sync.WaitGroup
|
||||
}
|
||||
|
||||
// NewService creates a notification service bound to the given store and WebSocket hub.
|
||||
func NewService(s store.NotificationStore, hub *events.Hub) *Service {
|
||||
return &Service{
|
||||
store: s,
|
||||
hub: hub,
|
||||
instanceName: "Chat Switchboard",
|
||||
retentionDays: 90,
|
||||
stopCleanup: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// WithPrefs attaches the preference store for per-user delivery control.
|
||||
func (s *Service) WithPrefs(ps store.NotificationPreferenceStore) *Service {
|
||||
s.prefStore = ps
|
||||
return s
|
||||
}
|
||||
|
||||
// WithUsers attaches the user store for email address lookup.
|
||||
func (s *Service) WithUsers(us store.UserStore) *Service {
|
||||
s.userStore = us
|
||||
return s
|
||||
}
|
||||
|
||||
// WithEmail enables email transport.
|
||||
func (s *Service) WithEmail(transport *EmailTransport, instanceName string) *Service {
|
||||
s.email = transport
|
||||
if instanceName != "" {
|
||||
s.instanceName = instanceName
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// WithRetention sets the retention period in days. Notifications older than
|
||||
// this are pruned by the background cleanup goroutine. Default: 90 days.
|
||||
func (s *Service) WithRetention(days int) *Service {
|
||||
if days > 0 {
|
||||
s.retentionDays = days
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Notify routes a notification through the user's delivery preferences:
|
||||
// in-app (persist + WebSocket) and/or email. Falls back to system defaults
|
||||
// if no user preferences are set.
|
||||
func (s *Service) Notify(ctx context.Context, n *models.Notification) error {
|
||||
prefs := s.resolvePrefs(ctx, n.UserID, n.Type)
|
||||
|
||||
// In-app: persist + WebSocket push
|
||||
if prefs.InApp {
|
||||
if err := s.store.Create(ctx, n); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if s.hub != nil {
|
||||
payload, _ := json.Marshal(n)
|
||||
s.hub.SendToUser(n.UserID, events.Event{
|
||||
Label: "notification.new",
|
||||
Payload: payload,
|
||||
Ts: time.Now().UnixMilli(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Email: async send (don't block the caller)
|
||||
if prefs.Email && s.email != nil && s.userStore != nil {
|
||||
user, err := s.userStore.GetByID(ctx, n.UserID)
|
||||
if err == nil && user != nil && user.Email != "" {
|
||||
subject := SubjectForNotification(n, s.instanceName)
|
||||
htmlBody := RenderHTML(n, s.instanceName)
|
||||
textBody := RenderText(n, s.instanceName)
|
||||
s.email.SendAsync(user.Email, subject, htmlBody, textBody)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// NotifyMany fans out a notification template to multiple users.
|
||||
// Each user gets their own copy. Errors are logged but don't fail the batch.
|
||||
func (s *Service) NotifyMany(ctx context.Context, userIDs []string, template models.Notification) {
|
||||
for _, uid := range userIDs {
|
||||
n := template
|
||||
n.ID = "" // let store generate
|
||||
n.UserID = uid
|
||||
if err := s.Notify(ctx, &n); err != nil {
|
||||
log.Printf("[notifications] failed to notify user %s: %v", uid, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// resolvePrefs returns the effective delivery preferences for a user + type.
|
||||
// Resolution chain: specific type → user '*' default → system default.
|
||||
func (s *Service) resolvePrefs(ctx context.Context, userID, notifType string) models.ResolvedPreference {
|
||||
if s.prefStore == nil {
|
||||
return models.SystemDefaultPreference
|
||||
}
|
||||
|
||||
// 1. Specific type preference
|
||||
pref, err := s.prefStore.Get(ctx, userID, notifType)
|
||||
if err == nil && pref != nil {
|
||||
return models.ResolvedPreference{InApp: pref.InApp, Email: pref.Email}
|
||||
}
|
||||
|
||||
// 2. User's wildcard default
|
||||
pref, err = s.prefStore.Get(ctx, userID, "*")
|
||||
if err == nil && pref != nil {
|
||||
return models.ResolvedPreference{InApp: pref.InApp, Email: pref.Email}
|
||||
}
|
||||
|
||||
// 3. System default
|
||||
return models.SystemDefaultPreference
|
||||
}
|
||||
|
||||
// StartCleanup launches a background goroutine that prunes old notifications daily.
|
||||
func (s *Service) StartCleanup() {
|
||||
s.cleanupWg.Add(1)
|
||||
go func() {
|
||||
defer s.cleanupWg.Done()
|
||||
ticker := time.NewTicker(24 * time.Hour)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Run once on startup (after a short delay to avoid contention)
|
||||
time.Sleep(5 * time.Minute)
|
||||
s.runCleanup()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
s.runCleanup()
|
||||
case <-s.stopCleanup:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// StopCleanup signals the cleanup goroutine to stop and waits for it.
|
||||
func (s *Service) StopCleanup() {
|
||||
close(s.stopCleanup)
|
||||
s.cleanupWg.Wait()
|
||||
}
|
||||
|
||||
func (s *Service) runCleanup() {
|
||||
cutoff := time.Now().AddDate(0, 0, -s.retentionDays)
|
||||
deleted, err := s.store.DeleteOlderThan(context.Background(), cutoff)
|
||||
if err != nil {
|
||||
log.Printf("[notifications] cleanup error: %v", err)
|
||||
return
|
||||
}
|
||||
if deleted > 0 {
|
||||
log.Printf("[notifications] cleaned up %d notifications older than %d days", deleted, s.retentionDays)
|
||||
}
|
||||
}
|
||||
137
server/notifications/service_test.go
Normal file
137
server/notifications/service_test.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
)
|
||||
|
||||
// mockPrefStore implements store.NotificationPreferenceStore for testing.
|
||||
type mockPrefStore struct {
|
||||
prefs map[string]map[string]*models.NotificationPreference // userID → type → pref
|
||||
}
|
||||
|
||||
func newMockPrefStore() *mockPrefStore {
|
||||
return &mockPrefStore{prefs: make(map[string]map[string]*models.NotificationPreference)}
|
||||
}
|
||||
|
||||
func (m *mockPrefStore) Get(_ context.Context, userID, notifType string) (*models.NotificationPreference, error) {
|
||||
if userPrefs, ok := m.prefs[userID]; ok {
|
||||
if p, ok := userPrefs[notifType]; ok {
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockPrefStore) ListForUser(_ context.Context, userID string) ([]models.NotificationPreference, error) {
|
||||
var result []models.NotificationPreference
|
||||
if userPrefs, ok := m.prefs[userID]; ok {
|
||||
for _, p := range userPrefs {
|
||||
result = append(result, *p)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *mockPrefStore) Upsert(_ context.Context, pref *models.NotificationPreference) error {
|
||||
if m.prefs[pref.UserID] == nil {
|
||||
m.prefs[pref.UserID] = make(map[string]*models.NotificationPreference)
|
||||
}
|
||||
m.prefs[pref.UserID][pref.Type] = pref
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockPrefStore) Delete(_ context.Context, userID, notifType string) error {
|
||||
if userPrefs, ok := m.prefs[userID]; ok {
|
||||
delete(userPrefs, notifType)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockPrefStore) set(userID, notifType string, inApp, email bool) {
|
||||
if m.prefs[userID] == nil {
|
||||
m.prefs[userID] = make(map[string]*models.NotificationPreference)
|
||||
}
|
||||
m.prefs[userID][notifType] = &models.NotificationPreference{
|
||||
UserID: userID,
|
||||
Type: notifType,
|
||||
InApp: inApp,
|
||||
Email: email,
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePrefs_SystemDefault(t *testing.T) {
|
||||
svc := &Service{prefStore: newMockPrefStore()}
|
||||
p := svc.resolvePrefs(context.Background(), "user1", "kb.ready")
|
||||
if !p.InApp || p.Email {
|
||||
t.Errorf("expected system default (in_app=true, email=false), got %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePrefs_NoPrefStore(t *testing.T) {
|
||||
svc := &Service{prefStore: nil}
|
||||
p := svc.resolvePrefs(context.Background(), "user1", "kb.ready")
|
||||
if !p.InApp || p.Email {
|
||||
t.Errorf("expected system default when no pref store, got %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePrefs_SpecificType(t *testing.T) {
|
||||
ps := newMockPrefStore()
|
||||
ps.set("user1", "kb.ready", true, true)
|
||||
svc := &Service{prefStore: ps}
|
||||
|
||||
p := svc.resolvePrefs(context.Background(), "user1", "kb.ready")
|
||||
if !p.InApp || !p.Email {
|
||||
t.Errorf("expected specific pref (in_app=true, email=true), got %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePrefs_WildcardFallback(t *testing.T) {
|
||||
ps := newMockPrefStore()
|
||||
ps.set("user1", "*", false, true) // user turned off in-app, turned on email globally
|
||||
svc := &Service{prefStore: ps}
|
||||
|
||||
// No specific "kb.ready" pref → falls to wildcard
|
||||
p := svc.resolvePrefs(context.Background(), "user1", "kb.ready")
|
||||
if p.InApp || !p.Email {
|
||||
t.Errorf("expected wildcard pref (in_app=false, email=true), got %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePrefs_SpecificOverridesWildcard(t *testing.T) {
|
||||
ps := newMockPrefStore()
|
||||
ps.set("user1", "*", false, true) // wildcard: no in-app, yes email
|
||||
ps.set("user1", "kb.ready", true, false) // specific: yes in-app, no email
|
||||
svc := &Service{prefStore: ps}
|
||||
|
||||
p := svc.resolvePrefs(context.Background(), "user1", "kb.ready")
|
||||
if !p.InApp || p.Email {
|
||||
t.Errorf("specific should override wildcard, got %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePrefs_DifferentUsersIsolated(t *testing.T) {
|
||||
ps := newMockPrefStore()
|
||||
ps.set("user1", "kb.ready", false, true)
|
||||
svc := &Service{prefStore: ps}
|
||||
|
||||
// user2 has no prefs → system default
|
||||
p := svc.resolvePrefs(context.Background(), "user2", "kb.ready")
|
||||
if !p.InApp || p.Email {
|
||||
t.Errorf("user2 should get system default, got %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePrefs_DisableBoth(t *testing.T) {
|
||||
ps := newMockPrefStore()
|
||||
ps.set("user1", "kb.ready", false, false)
|
||||
svc := &Service{prefStore: ps}
|
||||
|
||||
p := svc.resolvePrefs(context.Background(), "user1", "kb.ready")
|
||||
if p.InApp || p.Email {
|
||||
t.Errorf("expected both disabled, got %+v", p)
|
||||
}
|
||||
}
|
||||
146
server/notifications/sources.go
Normal file
146
server/notifications/sources.go
Normal file
@@ -0,0 +1,146 @@
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/events"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
// ── Role Fallback ───────────────────────────
|
||||
// Subscribes to the existing "role.fallback" EventBus event (emitted by
|
||||
// capabilities/resolver.go since v0.17.0) and creates notifications for
|
||||
// all admin users.
|
||||
|
||||
// roleFallbackPayload matches the payload emitted by the role resolver.
|
||||
type roleFallbackPayload struct {
|
||||
Role string `json:"role"`
|
||||
Primary string `json:"primary_model"`
|
||||
Fallback string `json:"fallback_model"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// RoleFallbackHandler returns an events.Handler that creates admin
|
||||
// notifications when a model role falls back.
|
||||
func RoleFallbackHandler(svc *Service, stores store.Stores) events.Handler {
|
||||
return func(e events.Event) {
|
||||
var p roleFallbackPayload
|
||||
if err := json.Unmarshal(e.Payload, &p); err != nil {
|
||||
log.Printf("[notifications] failed to parse role.fallback payload: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
title := fmt.Sprintf("Model fallback: %s → %s", p.Primary, p.Fallback)
|
||||
if p.Primary == "" {
|
||||
title = fmt.Sprintf("Role '%s' fell back to %s", p.Role, p.Fallback)
|
||||
}
|
||||
|
||||
body := ""
|
||||
if p.Error != "" {
|
||||
body = fmt.Sprintf("Primary model error: %s", p.Error)
|
||||
}
|
||||
|
||||
// Notify all admin users
|
||||
ctx := context.Background()
|
||||
admins, _, err := stores.Users.List(ctx, store.ListOptions{Limit: 500})
|
||||
if err != nil {
|
||||
log.Printf("[notifications] failed to list users for role.fallback: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, u := range admins {
|
||||
if u.Role != "admin" {
|
||||
continue
|
||||
}
|
||||
n := models.Notification{
|
||||
UserID: u.ID,
|
||||
Type: models.NotifTypeRoleFallback,
|
||||
Title: title,
|
||||
Body: body,
|
||||
}
|
||||
if err := svc.Notify(ctx, &n); err != nil {
|
||||
log.Printf("[notifications] failed to create role.fallback notification for %s: %v", u.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Knowledge Base ──────────────────────────
|
||||
// Called directly from the KB handler after indexing completes or fails.
|
||||
// Not a bus subscriber — the KB handler calls these functions.
|
||||
|
||||
// NotifyKBReady creates a notification when a knowledge base finishes indexing.
|
||||
func NotifyKBReady(svc *Service, userID, kbID, kbName string, chunkCount int) {
|
||||
if svc == nil {
|
||||
return
|
||||
}
|
||||
n := models.Notification{
|
||||
UserID: userID,
|
||||
Type: models.NotifTypeKBReady,
|
||||
Title: fmt.Sprintf("Knowledge base \"%s\" ready (%d chunks)", kbName, chunkCount),
|
||||
ResourceType: models.ResourceTypeKnowledgeBase,
|
||||
ResourceID: kbID,
|
||||
}
|
||||
if err := svc.Notify(context.Background(), &n); err != nil {
|
||||
log.Printf("[notifications] kb.ready failed for user %s: %v", userID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// NotifyKBError creates a notification when KB indexing fails.
|
||||
func NotifyKBError(svc *Service, userID, kbID, kbName, errMsg string) {
|
||||
if svc == nil {
|
||||
return
|
||||
}
|
||||
n := models.Notification{
|
||||
UserID: userID,
|
||||
Type: models.NotifTypeKBError,
|
||||
Title: fmt.Sprintf("Knowledge base \"%s\" indexing failed", kbName),
|
||||
Body: errMsg,
|
||||
ResourceType: models.ResourceTypeKnowledgeBase,
|
||||
ResourceID: kbID,
|
||||
}
|
||||
if err := svc.Notify(context.Background(), &n); err != nil {
|
||||
log.Printf("[notifications] kb.error failed for user %s: %v", userID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Group Membership ────────────────────────
|
||||
// Called directly from the group handler on add/remove.
|
||||
|
||||
// NotifyGroupMemberAdded creates a notification when a user is added to a group.
|
||||
func NotifyGroupMemberAdded(svc *Service, userID, groupID, groupName string) {
|
||||
if svc == nil {
|
||||
return
|
||||
}
|
||||
n := models.Notification{
|
||||
UserID: userID,
|
||||
Type: models.NotifTypeGrantChanged,
|
||||
Title: fmt.Sprintf("You were added to group \"%s\"", groupName),
|
||||
ResourceType: models.ResourceTypeGroup,
|
||||
ResourceID: groupID,
|
||||
}
|
||||
if err := svc.Notify(context.Background(), &n); err != nil {
|
||||
log.Printf("[notifications] grant.changed (add) failed for user %s: %v", userID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// NotifyGroupMemberRemoved creates a notification when a user is removed from a group.
|
||||
func NotifyGroupMemberRemoved(svc *Service, userID, groupID, groupName string) {
|
||||
if svc == nil {
|
||||
return
|
||||
}
|
||||
n := models.Notification{
|
||||
UserID: userID,
|
||||
Type: models.NotifTypeGrantChanged,
|
||||
Title: fmt.Sprintf("You were removed from group \"%s\"", groupName),
|
||||
ResourceType: models.ResourceTypeGroup,
|
||||
ResourceID: groupID,
|
||||
}
|
||||
if err := svc.Notify(context.Background(), &n); err != nil {
|
||||
log.Printf("[notifications] grant.changed (remove) failed for user %s: %v", userID, err)
|
||||
}
|
||||
}
|
||||
108
server/notifications/templates.go
Normal file
108
server/notifications/templates.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"html/template"
|
||||
"strings"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
)
|
||||
|
||||
// ── Template Data ───────────────────────────
|
||||
|
||||
type emailData struct {
|
||||
Title string
|
||||
Body string
|
||||
Type string
|
||||
ResourceType string
|
||||
InstanceName string
|
||||
}
|
||||
|
||||
// ── HTML Template ───────────────────────────
|
||||
|
||||
var htmlTmpl = template.Must(template.New("email").Parse(`<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="UTF-8"></head>
|
||||
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;">
|
||||
<div style="border-bottom: 2px solid #4a8af4; padding-bottom: 12px; margin-bottom: 20px;">
|
||||
<h2 style="margin: 0; color: #4a8af4;">{{.InstanceName}}</h2>
|
||||
</div>
|
||||
<div style="background: #f8f9fa; border-radius: 8px; padding: 16px; margin-bottom: 16px;">
|
||||
<h3 style="margin: 0 0 8px;">{{.Title}}</h3>
|
||||
{{if .Body}}<p style="margin: 0; color: #555;">{{.Body}}</p>{{end}}
|
||||
</div>
|
||||
<p style="font-size: 0.85em; color: #999;">
|
||||
This notification was sent by {{.InstanceName}}. You can adjust your notification preferences in Settings.
|
||||
</p>
|
||||
</body>
|
||||
</html>`))
|
||||
|
||||
// ── Plaintext Template ──────────────────────
|
||||
|
||||
var textTmpl = template.Must(template.New("email_text").Parse(`{{.InstanceName}} — Notification
|
||||
|
||||
{{.Title}}
|
||||
{{if .Body}}
|
||||
{{.Body}}
|
||||
{{end}}
|
||||
---
|
||||
Adjust your notification preferences in Settings.
|
||||
`))
|
||||
|
||||
// ── Render Functions ────────────────────────
|
||||
|
||||
// RenderHTML renders an HTML email body for a notification.
|
||||
func RenderHTML(n *models.Notification, instanceName string) string {
|
||||
if instanceName == "" {
|
||||
instanceName = "Chat Switchboard"
|
||||
}
|
||||
data := emailData{
|
||||
Title: n.Title,
|
||||
Body: n.Body,
|
||||
Type: n.Type,
|
||||
ResourceType: n.ResourceType,
|
||||
InstanceName: instanceName,
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := htmlTmpl.Execute(&buf, data); err != nil {
|
||||
return "<p>" + template.HTMLEscapeString(n.Title) + "</p>"
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// RenderText renders a plaintext email body for a notification.
|
||||
func RenderText(n *models.Notification, instanceName string) string {
|
||||
if instanceName == "" {
|
||||
instanceName = "Chat Switchboard"
|
||||
}
|
||||
data := emailData{
|
||||
Title: n.Title,
|
||||
Body: n.Body,
|
||||
Type: n.Type,
|
||||
ResourceType: n.ResourceType,
|
||||
InstanceName: instanceName,
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := textTmpl.Execute(&buf, data); err != nil {
|
||||
return n.Title
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// SubjectForNotification returns an email subject line.
|
||||
func SubjectForNotification(n *models.Notification, instanceName string) string {
|
||||
if instanceName == "" {
|
||||
instanceName = "Chat Switchboard"
|
||||
}
|
||||
prefix := "[" + instanceName + "] "
|
||||
switch {
|
||||
case strings.HasPrefix(n.Type, "kb."):
|
||||
return prefix + "Knowledge Base: " + n.Title
|
||||
case strings.HasPrefix(n.Type, "role."):
|
||||
return prefix + "Model Alert: " + n.Title
|
||||
case strings.HasPrefix(n.Type, "grant."):
|
||||
return prefix + "Access Change: " + n.Title
|
||||
default:
|
||||
return prefix + n.Title
|
||||
}
|
||||
}
|
||||
69
server/notifications/templates_test.go
Normal file
69
server/notifications/templates_test.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
)
|
||||
|
||||
func TestRenderHTML_Basic(t *testing.T) {
|
||||
n := &models.Notification{
|
||||
Title: "KB Ready",
|
||||
Body: "Your knowledge base has been processed.",
|
||||
Type: "kb.ready",
|
||||
}
|
||||
html := RenderHTML(n, "TestInstance")
|
||||
if !strings.Contains(html, "KB Ready") {
|
||||
t.Error("expected title in HTML output")
|
||||
}
|
||||
if !strings.Contains(html, "TestInstance") {
|
||||
t.Error("expected instance name in HTML output")
|
||||
}
|
||||
if !strings.Contains(html, "knowledge base") {
|
||||
t.Error("expected body in HTML output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderHTML_DefaultInstanceName(t *testing.T) {
|
||||
n := &models.Notification{Title: "Test"}
|
||||
html := RenderHTML(n, "")
|
||||
if !strings.Contains(html, "Chat Switchboard") {
|
||||
t.Error("expected default instance name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderText_Basic(t *testing.T) {
|
||||
n := &models.Notification{
|
||||
Title: "Model Fallback",
|
||||
Body: "GPT-4 fell back to GPT-3.5.",
|
||||
Type: "role.fallback",
|
||||
}
|
||||
text := RenderText(n, "MyInstance")
|
||||
if !strings.Contains(text, "Model Fallback") {
|
||||
t.Error("expected title in text output")
|
||||
}
|
||||
if !strings.Contains(text, "GPT-4 fell back") {
|
||||
t.Error("expected body in text output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubjectForNotification(t *testing.T) {
|
||||
tests := []struct {
|
||||
notifType string
|
||||
title string
|
||||
want string
|
||||
}{
|
||||
{"kb.ready", "KB Processed", "[Test] Knowledge Base: KB Processed"},
|
||||
{"role.fallback", "Fallback", "[Test] Model Alert: Fallback"},
|
||||
{"grant.changed", "Added to team", "[Test] Access Change: Added to team"},
|
||||
{"custom.type", "Hello", "[Test] Hello"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
n := &models.Notification{Type: tt.notifType, Title: tt.title}
|
||||
got := SubjectForNotification(n, "Test")
|
||||
if got != tt.want {
|
||||
t.Errorf("SubjectForNotification(%q, %q) = %q, want %q", tt.notifType, tt.title, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user