Add support for multiple instances of the same notifier type

This commit is contained in:
2025-10-17 16:39:33 -07:00
parent ba3fad9431
commit eb9e107f65
13 changed files with 322 additions and 153 deletions
+21 -19
View File
@@ -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<string, string> 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<string, string> 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<string, string> 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<string, string> metadata = 7;
google.protobuf.Timestamp scheduled_for = 8;
int32 max_retries = 9;
}
// SendNotificationResponse returns the result of sending a notification
+61 -43
View File
@@ -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" +
+4
View File
@@ -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,
+36 -21
View File
@@ -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)
}
}
}
+51 -32
View File
@@ -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
+47 -10
View File
@@ -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 ""
}
+4
View File
@@ -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"`
+7 -3
View File
@@ -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
+59 -11
View File
@@ -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:", &notifType); 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
+3
View File
@@ -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
+1
View File
@@ -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
+1
View File
@@ -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
+27 -14
View File
@@ -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)