diff --git a/api/grpc/notifier.proto b/api/grpc/notifier.proto index 4f89f35..722c2a2 100644 --- a/api/grpc/notifier.proto +++ b/api/grpc/notifier.proto @@ -66,18 +66,19 @@ enum NotificationStatus { message Notification { string id = 1; NotificationType type = 2; - Priority priority = 3; - NotificationStatus status = 4; - string subject = 5; - string body = 6; - repeated string recipients = 7; - map metadata = 8; - google.protobuf.Timestamp created_at = 9; - google.protobuf.Timestamp scheduled_for = 10; - google.protobuf.Timestamp sent_at = 11; - int32 retry_count = 12; - int32 max_retries = 13; - string last_error = 14; + string account = 3; // Optional account name for multi-account configs + Priority priority = 4; + NotificationStatus status = 5; + string subject = 6; + string body = 7; + repeated string recipients = 8; + map metadata = 9; + google.protobuf.Timestamp created_at = 10; + google.protobuf.Timestamp scheduled_for = 11; + google.protobuf.Timestamp sent_at = 12; + int32 retry_count = 13; + int32 max_retries = 14; + string last_error = 15; } // NotificationResult represents the outcome of sending a notification @@ -93,13 +94,14 @@ message NotificationResult { // SendNotificationRequest sends a single notification message SendNotificationRequest { NotificationType type = 1; - Priority priority = 2; - string subject = 3; - string body = 4; - repeated string recipients = 5; - map metadata = 6; - google.protobuf.Timestamp scheduled_for = 7; - int32 max_retries = 8; + string account = 2; // Optional account name for multi-account configs + Priority priority = 3; + string subject = 4; + string body = 5; + repeated string recipients = 6; + map metadata = 7; + google.protobuf.Timestamp scheduled_for = 8; + int32 max_retries = 9; } // SendNotificationResponse returns the result of sending a notification diff --git a/api/grpc/pb/api/grpc/notifier.pb.go b/api/grpc/pb/api/grpc/notifier.pb.go index 7ca86da..610fcae 100644 --- a/api/grpc/pb/api/grpc/notifier.pb.go +++ b/api/grpc/pb/api/grpc/notifier.pb.go @@ -201,18 +201,19 @@ type Notification struct { state protoimpl.MessageState `protogen:"open.v1"` Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` Type NotificationType `protobuf:"varint,2,opt,name=type,proto3,enum=notifier.v1.NotificationType" json:"type,omitempty"` - Priority Priority `protobuf:"varint,3,opt,name=priority,proto3,enum=notifier.v1.Priority" json:"priority,omitempty"` - Status NotificationStatus `protobuf:"varint,4,opt,name=status,proto3,enum=notifier.v1.NotificationStatus" json:"status,omitempty"` - Subject string `protobuf:"bytes,5,opt,name=subject,proto3" json:"subject,omitempty"` - Body string `protobuf:"bytes,6,opt,name=body,proto3" json:"body,omitempty"` - Recipients []string `protobuf:"bytes,7,rep,name=recipients,proto3" json:"recipients,omitempty"` - Metadata map[string]string `protobuf:"bytes,8,rep,name=metadata,proto3" json:"metadata,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` - CreatedAt *timestamppb.Timestamp `protobuf:"bytes,9,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` - ScheduledFor *timestamppb.Timestamp `protobuf:"bytes,10,opt,name=scheduled_for,json=scheduledFor,proto3" json:"scheduled_for,omitempty"` - SentAt *timestamppb.Timestamp `protobuf:"bytes,11,opt,name=sent_at,json=sentAt,proto3" json:"sent_at,omitempty"` - RetryCount int32 `protobuf:"varint,12,opt,name=retry_count,json=retryCount,proto3" json:"retry_count,omitempty"` - MaxRetries int32 `protobuf:"varint,13,opt,name=max_retries,json=maxRetries,proto3" json:"max_retries,omitempty"` - LastError string `protobuf:"bytes,14,opt,name=last_error,json=lastError,proto3" json:"last_error,omitempty"` + Account string `protobuf:"bytes,3,opt,name=account,proto3" json:"account,omitempty"` // Optional account name for multi-account configs + Priority Priority `protobuf:"varint,4,opt,name=priority,proto3,enum=notifier.v1.Priority" json:"priority,omitempty"` + Status NotificationStatus `protobuf:"varint,5,opt,name=status,proto3,enum=notifier.v1.NotificationStatus" json:"status,omitempty"` + Subject string `protobuf:"bytes,6,opt,name=subject,proto3" json:"subject,omitempty"` + Body string `protobuf:"bytes,7,opt,name=body,proto3" json:"body,omitempty"` + Recipients []string `protobuf:"bytes,8,rep,name=recipients,proto3" json:"recipients,omitempty"` + Metadata map[string]string `protobuf:"bytes,9,rep,name=metadata,proto3" json:"metadata,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + CreatedAt *timestamppb.Timestamp `protobuf:"bytes,10,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` + ScheduledFor *timestamppb.Timestamp `protobuf:"bytes,11,opt,name=scheduled_for,json=scheduledFor,proto3" json:"scheduled_for,omitempty"` + SentAt *timestamppb.Timestamp `protobuf:"bytes,12,opt,name=sent_at,json=sentAt,proto3" json:"sent_at,omitempty"` + RetryCount int32 `protobuf:"varint,13,opt,name=retry_count,json=retryCount,proto3" json:"retry_count,omitempty"` + MaxRetries int32 `protobuf:"varint,14,opt,name=max_retries,json=maxRetries,proto3" json:"max_retries,omitempty"` + LastError string `protobuf:"bytes,15,opt,name=last_error,json=lastError,proto3" json:"last_error,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -261,6 +262,13 @@ func (x *Notification) GetType() NotificationType { return NotificationType_NOTIFICATION_TYPE_UNSPECIFIED } +func (x *Notification) GetAccount() string { + if x != nil { + return x.Account + } + return "" +} + func (x *Notification) GetPriority() Priority { if x != nil { return x.Priority @@ -434,13 +442,14 @@ func (x *NotificationResult) GetProviderResponse() map[string]string { type SendNotificationRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Type NotificationType `protobuf:"varint,1,opt,name=type,proto3,enum=notifier.v1.NotificationType" json:"type,omitempty"` - Priority Priority `protobuf:"varint,2,opt,name=priority,proto3,enum=notifier.v1.Priority" json:"priority,omitempty"` - Subject string `protobuf:"bytes,3,opt,name=subject,proto3" json:"subject,omitempty"` - Body string `protobuf:"bytes,4,opt,name=body,proto3" json:"body,omitempty"` - Recipients []string `protobuf:"bytes,5,rep,name=recipients,proto3" json:"recipients,omitempty"` - Metadata map[string]string `protobuf:"bytes,6,rep,name=metadata,proto3" json:"metadata,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` - ScheduledFor *timestamppb.Timestamp `protobuf:"bytes,7,opt,name=scheduled_for,json=scheduledFor,proto3" json:"scheduled_for,omitempty"` - MaxRetries int32 `protobuf:"varint,8,opt,name=max_retries,json=maxRetries,proto3" json:"max_retries,omitempty"` + Account string `protobuf:"bytes,2,opt,name=account,proto3" json:"account,omitempty"` // Optional account name for multi-account configs + Priority Priority `protobuf:"varint,3,opt,name=priority,proto3,enum=notifier.v1.Priority" json:"priority,omitempty"` + Subject string `protobuf:"bytes,4,opt,name=subject,proto3" json:"subject,omitempty"` + Body string `protobuf:"bytes,5,opt,name=body,proto3" json:"body,omitempty"` + Recipients []string `protobuf:"bytes,6,rep,name=recipients,proto3" json:"recipients,omitempty"` + Metadata map[string]string `protobuf:"bytes,7,rep,name=metadata,proto3" json:"metadata,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + ScheduledFor *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=scheduled_for,json=scheduledFor,proto3" json:"scheduled_for,omitempty"` + MaxRetries int32 `protobuf:"varint,9,opt,name=max_retries,json=maxRetries,proto3" json:"max_retries,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -482,6 +491,13 @@ func (x *SendNotificationRequest) GetType() NotificationType { return NotificationType_NOTIFICATION_TYPE_UNSPECIFIED } +func (x *SendNotificationRequest) GetAccount() string { + if x != nil { + return x.Account + } + return "" +} + func (x *SendNotificationRequest) GetPriority() Priority { if x != nil { return x.Priority @@ -1375,29 +1391,30 @@ var File_api_grpc_notifier_proto protoreflect.FileDescriptor const file_api_grpc_notifier_proto_rawDesc = "" + "\n" + - "\x17api/grpc/notifier.proto\x12\vnotifier.v1\x1a\x1fgoogle/protobuf/timestamp.proto\"\x9f\x05\n" + + "\x17api/grpc/notifier.proto\x12\vnotifier.v1\x1a\x1fgoogle/protobuf/timestamp.proto\"\xb9\x05\n" + "\fNotification\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x121\n" + - "\x04type\x18\x02 \x01(\x0e2\x1d.notifier.v1.NotificationTypeR\x04type\x121\n" + - "\bpriority\x18\x03 \x01(\x0e2\x15.notifier.v1.PriorityR\bpriority\x127\n" + - "\x06status\x18\x04 \x01(\x0e2\x1f.notifier.v1.NotificationStatusR\x06status\x12\x18\n" + - "\asubject\x18\x05 \x01(\tR\asubject\x12\x12\n" + - "\x04body\x18\x06 \x01(\tR\x04body\x12\x1e\n" + + "\x04type\x18\x02 \x01(\x0e2\x1d.notifier.v1.NotificationTypeR\x04type\x12\x18\n" + + "\aaccount\x18\x03 \x01(\tR\aaccount\x121\n" + + "\bpriority\x18\x04 \x01(\x0e2\x15.notifier.v1.PriorityR\bpriority\x127\n" + + "\x06status\x18\x05 \x01(\x0e2\x1f.notifier.v1.NotificationStatusR\x06status\x12\x18\n" + + "\asubject\x18\x06 \x01(\tR\asubject\x12\x12\n" + + "\x04body\x18\a \x01(\tR\x04body\x12\x1e\n" + "\n" + - "recipients\x18\a \x03(\tR\n" + + "recipients\x18\b \x03(\tR\n" + "recipients\x12C\n" + - "\bmetadata\x18\b \x03(\v2'.notifier.v1.Notification.MetadataEntryR\bmetadata\x129\n" + + "\bmetadata\x18\t \x03(\v2'.notifier.v1.Notification.MetadataEntryR\bmetadata\x129\n" + "\n" + - "created_at\x18\t \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x12?\n" + - "\rscheduled_for\x18\n" + - " \x01(\v2\x1a.google.protobuf.TimestampR\fscheduledFor\x123\n" + - "\asent_at\x18\v \x01(\v2\x1a.google.protobuf.TimestampR\x06sentAt\x12\x1f\n" + - "\vretry_count\x18\f \x01(\x05R\n" + + "created_at\x18\n" + + " \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x12?\n" + + "\rscheduled_for\x18\v \x01(\v2\x1a.google.protobuf.TimestampR\fscheduledFor\x123\n" + + "\asent_at\x18\f \x01(\v2\x1a.google.protobuf.TimestampR\x06sentAt\x12\x1f\n" + + "\vretry_count\x18\r \x01(\x05R\n" + "retryCount\x12\x1f\n" + - "\vmax_retries\x18\r \x01(\x05R\n" + + "\vmax_retries\x18\x0e \x01(\x05R\n" + "maxRetries\x12\x1d\n" + "\n" + - "last_error\x18\x0e \x01(\tR\tlastError\x1a;\n" + + "last_error\x18\x0f \x01(\tR\tlastError\x1a;\n" + "\rMetadataEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xe5\x02\n" + @@ -1410,18 +1427,19 @@ const file_api_grpc_notifier_proto_rawDesc = "" + "\x11provider_response\x18\x06 \x03(\v25.notifier.v1.NotificationResult.ProviderResponseEntryR\x10providerResponse\x1aC\n" + "\x15ProviderResponseEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xbc\x03\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xd6\x03\n" + "\x17SendNotificationRequest\x121\n" + - "\x04type\x18\x01 \x01(\x0e2\x1d.notifier.v1.NotificationTypeR\x04type\x121\n" + - "\bpriority\x18\x02 \x01(\x0e2\x15.notifier.v1.PriorityR\bpriority\x12\x18\n" + - "\asubject\x18\x03 \x01(\tR\asubject\x12\x12\n" + - "\x04body\x18\x04 \x01(\tR\x04body\x12\x1e\n" + + "\x04type\x18\x01 \x01(\x0e2\x1d.notifier.v1.NotificationTypeR\x04type\x12\x18\n" + + "\aaccount\x18\x02 \x01(\tR\aaccount\x121\n" + + "\bpriority\x18\x03 \x01(\x0e2\x15.notifier.v1.PriorityR\bpriority\x12\x18\n" + + "\asubject\x18\x04 \x01(\tR\asubject\x12\x12\n" + + "\x04body\x18\x05 \x01(\tR\x04body\x12\x1e\n" + "\n" + - "recipients\x18\x05 \x03(\tR\n" + + "recipients\x18\x06 \x03(\tR\n" + "recipients\x12N\n" + - "\bmetadata\x18\x06 \x03(\v22.notifier.v1.SendNotificationRequest.MetadataEntryR\bmetadata\x12?\n" + - "\rscheduled_for\x18\a \x01(\v2\x1a.google.protobuf.TimestampR\fscheduledFor\x12\x1f\n" + - "\vmax_retries\x18\b \x01(\x05R\n" + + "\bmetadata\x18\a \x03(\v22.notifier.v1.SendNotificationRequest.MetadataEntryR\bmetadata\x12?\n" + + "\rscheduled_for\x18\b \x01(\v2\x1a.google.protobuf.TimestampR\fscheduledFor\x12\x1f\n" + + "\vmax_retries\x18\t \x01(\x05R\n" + "maxRetries\x1a;\n" + "\rMetadataEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + diff --git a/api/rest/types.go b/api/rest/types.go index bef6590..cfb5970 100644 --- a/api/rest/types.go +++ b/api/rest/types.go @@ -11,6 +11,7 @@ import ( // SendNotificationRequest is the REST API request for sending a notification type SendNotificationRequest struct { Type string `json:"type"` + Account string `json:"account,omitempty"` // Optional account name for multi-account configs Priority int `json:"priority,omitempty"` Subject string `json:"subject"` Body string `json:"body"` @@ -47,6 +48,7 @@ func (r *SendNotificationRequest) ToNotification() *domain.Notification { return &domain.Notification{ ID: uuid.New().String(), Type: domain.NotificationType(r.Type), + Account: r.Account, Priority: domain.Priority(r.Priority), Status: domain.StatusPending, Subject: r.Subject, @@ -79,6 +81,7 @@ type SendBatchNotificationsResponse struct { type Notification struct { ID string `json:"id"` Type string `json:"type"` + Account string `json:"account,omitempty"` Priority int `json:"priority"` Status string `json:"status"` Subject string `json:"subject"` @@ -98,6 +101,7 @@ func NotificationFromDomain(n *domain.Notification) Notification { return Notification{ ID: n.ID, Type: string(n.Type), + Account: n.Account, Priority: int(n.Priority), Status: string(n.Status), Subject: n.Subject, diff --git a/cmd/server/main.go b/cmd/server/main.go index fbe1620..e9457ec 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -69,8 +69,8 @@ func main() { logger.Infof("Supported notification types: %v", factory.SupportedTypes()) - // Create notification service - svc := service.NewNotificationService(factory, q, cfg.Queue.WorkerCount) + // Create notification service (pass config as account resolver) + svc := service.NewNotificationService(factory, q, cfg.Queue.WorkerCount, cfg) // Start workers if err := svc.Start(ctx); err != nil { @@ -132,45 +132,60 @@ func main() { func registerNotifiers(cfg *config.Config, factory *notifier.Factory, logger *logging.Logger) { if cfg.Notifiers.Stdout { stdoutNotifier := notifier.NewStdoutNotifier() - if err := factory.RegisterNotifier(domain.TypeStdout, stdoutNotifier); err != nil { + if err := factory.RegisterNotifier(domain.TypeStdout, "", stdoutNotifier); err != nil { logger.Fatalf("Failed to register stdout notifier: %v", err) } logger.Info("Registered stdout notifier") } - if cfg.Notifiers.SMTP != nil { - smtpNotifier, err := notifier.NewSMTPNotifier(cfg.Notifiers.SMTP) + // Register SMTP notifiers (now supports multiple accounts) + for accountName, smtpConfig := range cfg.Notifiers.SMTP { + smtpNotifier, err := notifier.NewSMTPNotifier(smtpConfig) if err != nil { - logger.Warnf("Failed to create SMTP notifier: %v", err) + logger.Warnf("Failed to create SMTP notifier for account '%s': %v", accountName, err) } else { - if err := factory.RegisterNotifier(domain.TypeEmail, smtpNotifier); err != nil { - logger.Fatalf("Failed to register SMTP notifier: %v", err) + if err := factory.RegisterNotifier(domain.TypeEmail, accountName, smtpNotifier); err != nil { + logger.Fatalf("Failed to register SMTP notifier for account '%s': %v", accountName, err) } - logger.Info("Registered SMTP notifier") + defaultStr := "" + if smtpConfig.Default { + defaultStr = " (default)" + } + logger.Infof("Registered SMTP notifier for account '%s'%s", accountName, defaultStr) } } - if cfg.Notifiers.Slack != nil { - slackNotifier, err := notifier.NewSlackNotifier(cfg.Notifiers.Slack) + // Register Slack notifiers (now supports multiple accounts) + for accountName, slackConfig := range cfg.Notifiers.Slack { + slackNotifier, err := notifier.NewSlackNotifier(slackConfig) if err != nil { - logger.Warnf("Failed to create Slack notifier: %v", err) + logger.Warnf("Failed to create Slack notifier for account '%s': %v", accountName, err) } else { - if err := factory.RegisterNotifier(domain.TypeSlack, slackNotifier); err != nil { - logger.Fatalf("Failed to register Slack notifier: %v", err) + if err := factory.RegisterNotifier(domain.TypeSlack, accountName, slackNotifier); err != nil { + logger.Fatalf("Failed to register Slack notifier for account '%s': %v", accountName, err) } - logger.Info("Registered Slack notifier") + defaultStr := "" + if slackConfig.Default { + defaultStr = " (default)" + } + logger.Infof("Registered Slack notifier for account '%s'%s", accountName, defaultStr) } } - if cfg.Notifiers.Ntfy != nil { - ntfyNotifier, err := notifier.NewNtfyNotifier(cfg.Notifiers.Ntfy) + // Register Ntfy notifiers (now supports multiple accounts) + for accountName, ntfyConfig := range cfg.Notifiers.Ntfy { + ntfyNotifier, err := notifier.NewNtfyNotifier(ntfyConfig) if err != nil { - logger.Warnf("Failed to create Ntfy notifier: %v", err) + logger.Warnf("Failed to create Ntfy notifier for account '%s': %v", accountName, err) } else { - if err := factory.RegisterNotifier(domain.TypeNtfy, ntfyNotifier); err != nil { - logger.Fatalf("Failed to register Ntfy notifier: %v", err) + if err := factory.RegisterNotifier(domain.TypeNtfy, accountName, ntfyNotifier); err != nil { + logger.Fatalf("Failed to register Ntfy notifier for account '%s': %v", accountName, err) } - logger.Info("Registered Ntfy notifier") + defaultStr := "" + if ntfyConfig.Default { + defaultStr = " (default)" + } + logger.Infof("Registered Ntfy notifier for account '%s'%s", accountName, defaultStr) } } } diff --git a/config.yaml b/config.yaml index b7e741a..01e30b2 100644 --- a/config.yaml +++ b/config.yaml @@ -34,44 +34,63 @@ notifiers: # Enable stdout notifier (useful for development/debugging) stdout: true - # SMTP email configuration + # SMTP email configuration (supports multiple accounts) smtp: - host: "smtp.gmail.com" - port: 587 - username: "your-email@gmail.com" - password: "your-app-password" - from: "notifications@yourservice.com" - use_tls: true + # Personal email account (marked as default) + personal: + host: "smtp.gmail.com" + port: 587 + username: "your-personal@gmail.com" + password: "your-app-password" + from: "your-personal@gmail.com" + use_tls: true + default: true # This account will be used if no account is specified in the API request - # Slack configuration + # Work email account + # work: + # host: "smtp.company.com" + # port: 587 + # username: "you@company.com" + # password: "your-work-password" + # from: "notifications@company.com" + # use_tls: true + # default: false + + # Slack configuration (supports multiple workspaces/webhooks) slack: - webhook_url: "https://hooks.slack.com/services/YOUR/WEBHOOK/URL" - # token: "xoxb-your-bot-token" # Alternative to webhook - # channel: "#notifications" # Default channel - username: "Notifier Bot" - icon_emoji: ":bell:" - # Channel-specific webhooks - # webhooks: - # "#alerts": "https://hooks.slack.com/services/ALERTS/WEBHOOK" - # "#monitoring": "https://hooks.slack.com/services/MONITORING/WEBHOOK" + # Main workspace (marked as default) + main: + webhook_url: "https://hooks.slack.com/services/YOUR/WEBHOOK/URL" + username: "Notifier Bot" + icon_emoji: ":bell:" + default: true # This workspace will be used if no account is specified - # Ntfy configuration + # Additional workspace example + # team-a: + # webhook_url: "https://hooks.slack.com/services/TEAM-A/WEBHOOK/URL" + # username: "Team A Bot" + # icon_emoji: ":rocket:" + # # Channel-specific webhooks within this workspace + # webhooks: + # "#alerts": "https://hooks.slack.com/services/ALERTS/WEBHOOK" + # "#monitoring": "https://hooks.slack.com/services/MONITORING/WEBHOOK" + + # Ntfy configuration (supports multiple servers) ntfy: - server_url: "https://ntfy.sh" + # Public ntfy.sh server (marked as default) + public: + server_url: "https://ntfy.sh" + # token: "tk_your_access_token" # Token-based auth (recommended) + # default_topic: "notifications" + default: true # This server will be used if no account is specified - # Token-based authentication (recommended for ntfy.sh) - # Supports both access tokens (tk_...) and publish tokens - # token: "tk_your_access_token" - - # Basic authentication (alternative to token) - # username: "your-username" - # password: "your-password" - - # Default topic if recipient not specified - # default_topic: "default-notifications" - - # Skip TLS verification (for self-hosted servers with self-signed certs) - # insecure_skip_verify: false + # Private ntfy server example + # private: + # server_url: "https://ntfy.mycompany.com" + # username: "your-username" + # password: "your-password" + # default_topic: "company-notifications" + # insecure_skip_verify: false # Set to true for self-signed certs logging: level: "info" # Options: debug, info, warn, error diff --git a/internal/config/config.go b/internal/config/config.go index 58a7193..b4a9d04 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -29,10 +29,10 @@ type ServerConfig struct { // NotifiersConfig contains configuration for all notifier types type NotifiersConfig struct { - SMTP *notifier.SMTPConfig `mapstructure:"smtp"` - Slack *notifier.SlackConfig `mapstructure:"slack"` - Ntfy *notifier.NtfyConfig `mapstructure:"ntfy"` - Stdout bool `mapstructure:"stdout"` // Enable stdout notifier + SMTP map[string]*notifier.SMTPConfig `mapstructure:"smtp"` + Slack map[string]*notifier.SlackConfig `mapstructure:"slack"` + Ntfy map[string]*notifier.NtfyConfig `mapstructure:"ntfy"` + Stdout bool `mapstructure:"stdout"` // Enable stdout notifier } // LoggingConfig contains logging configuration @@ -185,9 +185,9 @@ func (c *Config) Validate() error { // HasAnyNotifier checks if at least one notifier is configured func (c *Config) HasAnyNotifier() bool { return c.Notifiers.Stdout || - c.Notifiers.SMTP != nil || - c.Notifiers.Slack != nil || - c.Notifiers.Ntfy != nil + len(c.Notifiers.SMTP) > 0 || + len(c.Notifiers.Slack) > 0 || + len(c.Notifiers.Ntfy) > 0 } // GetEnabledNotifiers returns a list of enabled notifier types @@ -197,15 +197,52 @@ func (c *Config) GetEnabledNotifiers() []domain.NotificationType { if c.Notifiers.Stdout { enabled = append(enabled, domain.TypeStdout) } - if c.Notifiers.SMTP != nil { + if len(c.Notifiers.SMTP) > 0 { enabled = append(enabled, domain.TypeEmail) } - if c.Notifiers.Slack != nil { + if len(c.Notifiers.Slack) > 0 { enabled = append(enabled, domain.TypeSlack) } - if c.Notifiers.Ntfy != nil { + if len(c.Notifiers.Ntfy) > 0 { enabled = append(enabled, domain.TypeNtfy) } return enabled } + +// GetDefaultAccount returns the default account name for a notifier type, or the first account if no default is set +func (c *Config) GetDefaultAccount(notifierType domain.NotificationType) string { + switch notifierType { + case domain.TypeEmail: + for name, cfg := range c.Notifiers.SMTP { + if cfg.Default { + return name + } + } + // Return first account if no default is set + for name := range c.Notifiers.SMTP { + return name + } + case domain.TypeSlack: + for name, cfg := range c.Notifiers.Slack { + if cfg.Default { + return name + } + } + // Return first account if no default is set + for name := range c.Notifiers.Slack { + return name + } + case domain.TypeNtfy: + for name, cfg := range c.Notifiers.Ntfy { + if cfg.Default { + return name + } + } + // Return first account if no default is set + for name := range c.Notifiers.Ntfy { + return name + } + } + return "" +} diff --git a/internal/domain/notification.go b/internal/domain/notification.go index 2ee88d8..1408cc3 100644 --- a/internal/domain/notification.go +++ b/internal/domain/notification.go @@ -44,6 +44,10 @@ type Notification struct { // Type specifies which notifier should handle this notification Type NotificationType `json:"type"` + // Account specifies which named account/instance to use for this notifier type (optional) + // If not specified, the default account for the notifier type will be used + Account string `json:"account,omitempty"` + // Priority determines urgency and retry behavior Priority Priority `json:"priority"` diff --git a/internal/domain/notifier.go b/internal/domain/notifier.go index 02addc1..fa9447d 100644 --- a/internal/domain/notifier.go +++ b/internal/domain/notifier.go @@ -21,14 +21,18 @@ type Notifier interface { // NotifierFactory creates notifier instances based on configuration type NotifierFactory interface { - // Create creates a notifier for the given type - Create(notificationType NotificationType) (Notifier, error) + // Create creates a notifier for the given type and account + // If account is empty, the default account for the type will be used + Create(notificationType NotificationType, account string) (Notifier, error) // RegisterNotifier registers a custom notifier implementation - RegisterNotifier(notificationType NotificationType, notifier Notifier) error + RegisterNotifier(notificationType NotificationType, account string, notifier Notifier) error // SupportedTypes returns all supported notification types SupportedTypes() []NotificationType + + // GetAccounts returns all registered accounts for a given notification type + GetAccounts(notificationType NotificationType) []string } // NotificationService is the high-level service interface for managing notifications diff --git a/internal/notifier/notifier.go b/internal/notifier/notifier.go index 7339c95..38d2928 100644 --- a/internal/notifier/notifier.go +++ b/internal/notifier/notifier.go @@ -10,24 +10,38 @@ import ( // Factory creates and manages notifier instances type Factory struct { - notifiers map[domain.NotificationType]domain.Notifier + // Map of "type:account" -> notifier instance + notifiers map[string]domain.Notifier mu sync.RWMutex } // NewFactory creates a new notifier factory func NewFactory() *Factory { return &Factory{ - notifiers: make(map[domain.NotificationType]domain.Notifier), + notifiers: make(map[string]domain.Notifier), } } -// Create creates a notifier for the given type -func (f *Factory) Create(notificationType domain.NotificationType) (domain.Notifier, error) { +// makeKey creates a compound key from notification type and account +func makeKey(notificationType domain.NotificationType, account string) string { + if account == "" { + // For backward compatibility, if account is empty, just use the type + return string(notificationType) + } + return fmt.Sprintf("%s:%s", notificationType, account) +} + +// Create creates a notifier for the given type and account +func (f *Factory) Create(notificationType domain.NotificationType, account string) (domain.Notifier, error) { f.mu.RLock() defer f.mu.RUnlock() - notifier, exists := f.notifiers[notificationType] + key := makeKey(notificationType, account) + notifier, exists := f.notifiers[key] if !exists { + if account != "" { + return nil, fmt.Errorf("unsupported notification type: %s with account: %s", notificationType, account) + } return nil, fmt.Errorf("unsupported notification type: %s", notificationType) } @@ -35,31 +49,65 @@ func (f *Factory) Create(notificationType domain.NotificationType) (domain.Notif } // RegisterNotifier registers a custom notifier implementation -func (f *Factory) RegisterNotifier(notificationType domain.NotificationType, notifier domain.Notifier) error { +func (f *Factory) RegisterNotifier(notificationType domain.NotificationType, account string, notifier domain.Notifier) error { f.mu.Lock() defer f.mu.Unlock() - if _, exists := f.notifiers[notificationType]; exists { + key := makeKey(notificationType, account) + if _, exists := f.notifiers[key]; exists { + if account != "" { + return fmt.Errorf("notifier already registered for type: %s with account: %s", notificationType, account) + } return fmt.Errorf("notifier already registered for type: %s", notificationType) } - f.notifiers[notificationType] = notifier + f.notifiers[key] = notifier return nil } -// SupportedTypes returns all supported notification types +// SupportedTypes returns all supported notification types (unique types only) func (f *Factory) SupportedTypes() []domain.NotificationType { f.mu.RLock() defer f.mu.RUnlock() - types := make([]domain.NotificationType, 0, len(f.notifiers)) - for t := range f.notifiers { + typeMap := make(map[domain.NotificationType]bool) + for key := range f.notifiers { + // Extract the type from the key (type:account) + var notifType domain.NotificationType + if n, err := fmt.Sscanf(key, "%s:", ¬ifType); err == nil && n > 0 { + typeMap[notifType] = true + } else { + // Backward compatibility: key might just be the type + typeMap[domain.NotificationType(key)] = true + } + } + + types := make([]domain.NotificationType, 0, len(typeMap)) + for t := range typeMap { types = append(types, t) } return types } +// GetAccounts returns all registered accounts for a given notification type +func (f *Factory) GetAccounts(notificationType domain.NotificationType) []string { + f.mu.RLock() + defer f.mu.RUnlock() + + accounts := []string{} + prefix := string(notificationType) + ":" + + for key := range f.notifiers { + if len(key) > len(prefix) && key[:len(prefix)] == prefix { + account := key[len(prefix):] + accounts = append(accounts, account) + } + } + + return accounts +} + // BaseNotifier provides common functionality for all notifiers type BaseNotifier struct { notificationType domain.NotificationType diff --git a/internal/notifier/ntfy.go b/internal/notifier/ntfy.go index 6a72ea2..dff0f14 100644 --- a/internal/notifier/ntfy.go +++ b/internal/notifier/ntfy.go @@ -32,6 +32,9 @@ type NtfyConfig struct { // InsecureSkipVerify skips TLS verification (for self-hosted servers with self-signed certs) InsecureSkipVerify bool `mapstructure:"insecure_skip_verify"` + + // Default marks this instance as default + Default bool `mapstructure:"default"` } // NtfyNotifier sends notifications via ntfy.sh diff --git a/internal/notifier/slack.go b/internal/notifier/slack.go index a91f286..dca9556 100644 --- a/internal/notifier/slack.go +++ b/internal/notifier/slack.go @@ -19,6 +19,7 @@ type SlackConfig struct { Username string `mapstructure:"username"` IconEmoji string `mapstructure:"icon_emoji"` Webhooks map[string]string `mapstructure:"webhooks"` // Channel-specific webhooks + Default bool `mapstructure:"default"` // Mark this instance as default } // SlackNotifier sends notifications to Slack diff --git a/internal/notifier/smtp.go b/internal/notifier/smtp.go index 319494f..58bedb0 100644 --- a/internal/notifier/smtp.go +++ b/internal/notifier/smtp.go @@ -18,6 +18,7 @@ type SMTPConfig struct { Password string `mapstructure:"password"` From string `mapstructure:"from"` UseTLS bool `mapstructure:"use_tls"` + Default bool `mapstructure:"default"` // Mark this instance as default } // SMTPNotifier sends notifications via email using SMTP diff --git a/internal/service/service.go b/internal/service/service.go index c1fab8e..ef89f3b 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -9,29 +9,36 @@ import ( "github.com/igodwin/notifier/internal/domain" ) +// AccountResolver is an interface for resolving default accounts +type AccountResolver interface { + GetDefaultAccount(notifierType domain.NotificationType) string +} + // NotificationService implements the domain.NotificationService interface type NotificationService struct { - factory domain.NotifierFactory - queue domain.Queue - notifications map[string]*domain.Notification - mu sync.RWMutex - workerCount int - stopChan chan struct{} - wg sync.WaitGroup + factory domain.NotifierFactory + queue domain.Queue + accountResolver AccountResolver + notifications map[string]*domain.Notification + mu sync.RWMutex + workerCount int + stopChan chan struct{} + wg sync.WaitGroup } // NewNotificationService creates a new notification service -func NewNotificationService(factory domain.NotifierFactory, queue domain.Queue, workerCount int) *NotificationService { +func NewNotificationService(factory domain.NotifierFactory, queue domain.Queue, workerCount int, accountResolver AccountResolver) *NotificationService { if workerCount <= 0 { workerCount = 10 } return &NotificationService{ - factory: factory, - queue: queue, - notifications: make(map[string]*domain.Notification), - workerCount: workerCount, - stopChan: make(chan struct{}), + factory: factory, + queue: queue, + accountResolver: accountResolver, + notifications: make(map[string]*domain.Notification), + workerCount: workerCount, + stopChan: make(chan struct{}), } } @@ -90,8 +97,14 @@ func (s *NotificationService) worker(ctx context.Context, id int) { func (s *NotificationService) processNotification(ctx context.Context, msg *domain.QueueMessage) { notification := msg.Notification + // Resolve account if not specified + account := notification.Account + if account == "" && s.accountResolver != nil { + account = s.accountResolver.GetDefaultAccount(notification.Type) + } + // Get the appropriate notifier - notifier, err := s.factory.Create(notification.Type) + notifier, err := s.factory.Create(notification.Type, account) if err != nil { notification.Status = domain.StatusFailed notification.LastError = fmt.Sprintf("failed to create notifier: %v", err)