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 { message Notification {
string id = 1; string id = 1;
NotificationType type = 2; NotificationType type = 2;
Priority priority = 3; string account = 3; // Optional account name for multi-account configs
NotificationStatus status = 4; Priority priority = 4;
string subject = 5; NotificationStatus status = 5;
string body = 6; string subject = 6;
repeated string recipients = 7; string body = 7;
map<string, string> metadata = 8; repeated string recipients = 8;
google.protobuf.Timestamp created_at = 9; map<string, string> metadata = 9;
google.protobuf.Timestamp scheduled_for = 10; google.protobuf.Timestamp created_at = 10;
google.protobuf.Timestamp sent_at = 11; google.protobuf.Timestamp scheduled_for = 11;
int32 retry_count = 12; google.protobuf.Timestamp sent_at = 12;
int32 max_retries = 13; int32 retry_count = 13;
string last_error = 14; int32 max_retries = 14;
string last_error = 15;
} }
// NotificationResult represents the outcome of sending a notification // NotificationResult represents the outcome of sending a notification
@@ -93,13 +94,14 @@ message NotificationResult {
// SendNotificationRequest sends a single notification // SendNotificationRequest sends a single notification
message SendNotificationRequest { message SendNotificationRequest {
NotificationType type = 1; NotificationType type = 1;
Priority priority = 2; string account = 2; // Optional account name for multi-account configs
string subject = 3; Priority priority = 3;
string body = 4; string subject = 4;
repeated string recipients = 5; string body = 5;
map<string, string> metadata = 6; repeated string recipients = 6;
google.protobuf.Timestamp scheduled_for = 7; map<string, string> metadata = 7;
int32 max_retries = 8; google.protobuf.Timestamp scheduled_for = 8;
int32 max_retries = 9;
} }
// SendNotificationResponse returns the result of sending a notification // 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"` state protoimpl.MessageState `protogen:"open.v1"`
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` 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"` 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"` Account string `protobuf:"bytes,3,opt,name=account,proto3" json:"account,omitempty"` // Optional account name for multi-account configs
Status NotificationStatus `protobuf:"varint,4,opt,name=status,proto3,enum=notifier.v1.NotificationStatus" json:"status,omitempty"` Priority Priority `protobuf:"varint,4,opt,name=priority,proto3,enum=notifier.v1.Priority" json:"priority,omitempty"`
Subject string `protobuf:"bytes,5,opt,name=subject,proto3" json:"subject,omitempty"` Status NotificationStatus `protobuf:"varint,5,opt,name=status,proto3,enum=notifier.v1.NotificationStatus" json:"status,omitempty"`
Body string `protobuf:"bytes,6,opt,name=body,proto3" json:"body,omitempty"` Subject string `protobuf:"bytes,6,opt,name=subject,proto3" json:"subject,omitempty"`
Recipients []string `protobuf:"bytes,7,rep,name=recipients,proto3" json:"recipients,omitempty"` Body string `protobuf:"bytes,7,opt,name=body,proto3" json:"body,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"` Recipients []string `protobuf:"bytes,8,rep,name=recipients,proto3" json:"recipients,omitempty"`
CreatedAt *timestamppb.Timestamp `protobuf:"bytes,9,opt,name=created_at,json=createdAt,proto3" json:"created_at,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"`
ScheduledFor *timestamppb.Timestamp `protobuf:"bytes,10,opt,name=scheduled_for,json=scheduledFor,proto3" json:"scheduled_for,omitempty"` CreatedAt *timestamppb.Timestamp `protobuf:"bytes,10,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"`
SentAt *timestamppb.Timestamp `protobuf:"bytes,11,opt,name=sent_at,json=sentAt,proto3" json:"sent_at,omitempty"` ScheduledFor *timestamppb.Timestamp `protobuf:"bytes,11,opt,name=scheduled_for,json=scheduledFor,proto3" json:"scheduled_for,omitempty"`
RetryCount int32 `protobuf:"varint,12,opt,name=retry_count,json=retryCount,proto3" json:"retry_count,omitempty"` SentAt *timestamppb.Timestamp `protobuf:"bytes,12,opt,name=sent_at,json=sentAt,proto3" json:"sent_at,omitempty"`
MaxRetries int32 `protobuf:"varint,13,opt,name=max_retries,json=maxRetries,proto3" json:"max_retries,omitempty"` RetryCount int32 `protobuf:"varint,13,opt,name=retry_count,json=retryCount,proto3" json:"retry_count,omitempty"`
LastError string `protobuf:"bytes,14,opt,name=last_error,json=lastError,proto3" json:"last_error,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 unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@@ -261,6 +262,13 @@ func (x *Notification) GetType() NotificationType {
return NotificationType_NOTIFICATION_TYPE_UNSPECIFIED return NotificationType_NOTIFICATION_TYPE_UNSPECIFIED
} }
func (x *Notification) GetAccount() string {
if x != nil {
return x.Account
}
return ""
}
func (x *Notification) GetPriority() Priority { func (x *Notification) GetPriority() Priority {
if x != nil { if x != nil {
return x.Priority return x.Priority
@@ -434,13 +442,14 @@ func (x *NotificationResult) GetProviderResponse() map[string]string {
type SendNotificationRequest struct { type SendNotificationRequest struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
Type NotificationType `protobuf:"varint,1,opt,name=type,proto3,enum=notifier.v1.NotificationType" json:"type,omitempty"` 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"` Account string `protobuf:"bytes,2,opt,name=account,proto3" json:"account,omitempty"` // Optional account name for multi-account configs
Subject string `protobuf:"bytes,3,opt,name=subject,proto3" json:"subject,omitempty"` Priority Priority `protobuf:"varint,3,opt,name=priority,proto3,enum=notifier.v1.Priority" json:"priority,omitempty"`
Body string `protobuf:"bytes,4,opt,name=body,proto3" json:"body,omitempty"` Subject string `protobuf:"bytes,4,opt,name=subject,proto3" json:"subject,omitempty"`
Recipients []string `protobuf:"bytes,5,rep,name=recipients,proto3" json:"recipients,omitempty"` Body string `protobuf:"bytes,5,opt,name=body,proto3" json:"body,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"` Recipients []string `protobuf:"bytes,6,rep,name=recipients,proto3" json:"recipients,omitempty"`
ScheduledFor *timestamppb.Timestamp `protobuf:"bytes,7,opt,name=scheduled_for,json=scheduledFor,proto3" json:"scheduled_for,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"`
MaxRetries int32 `protobuf:"varint,8,opt,name=max_retries,json=maxRetries,proto3" json:"max_retries,omitempty"` 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 unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@@ -482,6 +491,13 @@ func (x *SendNotificationRequest) GetType() NotificationType {
return NotificationType_NOTIFICATION_TYPE_UNSPECIFIED return NotificationType_NOTIFICATION_TYPE_UNSPECIFIED
} }
func (x *SendNotificationRequest) GetAccount() string {
if x != nil {
return x.Account
}
return ""
}
func (x *SendNotificationRequest) GetPriority() Priority { func (x *SendNotificationRequest) GetPriority() Priority {
if x != nil { if x != nil {
return x.Priority return x.Priority
@@ -1375,29 +1391,30 @@ var File_api_grpc_notifier_proto protoreflect.FileDescriptor
const file_api_grpc_notifier_proto_rawDesc = "" + const file_api_grpc_notifier_proto_rawDesc = "" +
"\n" + "\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" + "\fNotification\x12\x0e\n" +
"\x02id\x18\x01 \x01(\tR\x02id\x121\n" + "\x02id\x18\x01 \x01(\tR\x02id\x121\n" +
"\x04type\x18\x02 \x01(\x0e2\x1d.notifier.v1.NotificationTypeR\x04type\x121\n" + "\x04type\x18\x02 \x01(\x0e2\x1d.notifier.v1.NotificationTypeR\x04type\x12\x18\n" +
"\bpriority\x18\x03 \x01(\x0e2\x15.notifier.v1.PriorityR\bpriority\x127\n" + "\aaccount\x18\x03 \x01(\tR\aaccount\x121\n" +
"\x06status\x18\x04 \x01(\x0e2\x1f.notifier.v1.NotificationStatusR\x06status\x12\x18\n" + "\bpriority\x18\x04 \x01(\x0e2\x15.notifier.v1.PriorityR\bpriority\x127\n" +
"\asubject\x18\x05 \x01(\tR\asubject\x12\x12\n" + "\x06status\x18\x05 \x01(\x0e2\x1f.notifier.v1.NotificationStatusR\x06status\x12\x18\n" +
"\x04body\x18\x06 \x01(\tR\x04body\x12\x1e\n" + "\asubject\x18\x06 \x01(\tR\asubject\x12\x12\n" +
"\x04body\x18\a \x01(\tR\x04body\x12\x1e\n" +
"\n" + "\n" +
"recipients\x18\a \x03(\tR\n" + "recipients\x18\b \x03(\tR\n" +
"recipients\x12C\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" + "\n" +
"created_at\x18\t \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x12?\n" + "created_at\x18\n" +
"\rscheduled_for\x18\n" + " \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x12?\n" +
" \x01(\v2\x1a.google.protobuf.TimestampR\fscheduledFor\x123\n" + "\rscheduled_for\x18\v \x01(\v2\x1a.google.protobuf.TimestampR\fscheduledFor\x123\n" +
"\asent_at\x18\v \x01(\v2\x1a.google.protobuf.TimestampR\x06sentAt\x12\x1f\n" + "\asent_at\x18\f \x01(\v2\x1a.google.protobuf.TimestampR\x06sentAt\x12\x1f\n" +
"\vretry_count\x18\f \x01(\x05R\n" + "\vretry_count\x18\r \x01(\x05R\n" +
"retryCount\x12\x1f\n" + "retryCount\x12\x1f\n" +
"\vmax_retries\x18\r \x01(\x05R\n" + "\vmax_retries\x18\x0e \x01(\x05R\n" +
"maxRetries\x12\x1d\n" + "maxRetries\x12\x1d\n" +
"\n" + "\n" +
"last_error\x18\x0e \x01(\tR\tlastError\x1a;\n" + "last_error\x18\x0f \x01(\tR\tlastError\x1a;\n" +
"\rMetadataEntry\x12\x10\n" + "\rMetadataEntry\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xe5\x02\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" + "\x11provider_response\x18\x06 \x03(\v25.notifier.v1.NotificationResult.ProviderResponseEntryR\x10providerResponse\x1aC\n" +
"\x15ProviderResponseEntry\x12\x10\n" + "\x15ProviderResponseEntry\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\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" + "\x17SendNotificationRequest\x121\n" +
"\x04type\x18\x01 \x01(\x0e2\x1d.notifier.v1.NotificationTypeR\x04type\x121\n" + "\x04type\x18\x01 \x01(\x0e2\x1d.notifier.v1.NotificationTypeR\x04type\x12\x18\n" +
"\bpriority\x18\x02 \x01(\x0e2\x15.notifier.v1.PriorityR\bpriority\x12\x18\n" + "\aaccount\x18\x02 \x01(\tR\aaccount\x121\n" +
"\asubject\x18\x03 \x01(\tR\asubject\x12\x12\n" + "\bpriority\x18\x03 \x01(\x0e2\x15.notifier.v1.PriorityR\bpriority\x12\x18\n" +
"\x04body\x18\x04 \x01(\tR\x04body\x12\x1e\n" + "\asubject\x18\x04 \x01(\tR\asubject\x12\x12\n" +
"\x04body\x18\x05 \x01(\tR\x04body\x12\x1e\n" +
"\n" + "\n" +
"recipients\x18\x05 \x03(\tR\n" + "recipients\x18\x06 \x03(\tR\n" +
"recipients\x12N\n" + "recipients\x12N\n" +
"\bmetadata\x18\x06 \x03(\v22.notifier.v1.SendNotificationRequest.MetadataEntryR\bmetadata\x12?\n" + "\bmetadata\x18\a \x03(\v22.notifier.v1.SendNotificationRequest.MetadataEntryR\bmetadata\x12?\n" +
"\rscheduled_for\x18\a \x01(\v2\x1a.google.protobuf.TimestampR\fscheduledFor\x12\x1f\n" + "\rscheduled_for\x18\b \x01(\v2\x1a.google.protobuf.TimestampR\fscheduledFor\x12\x1f\n" +
"\vmax_retries\x18\b \x01(\x05R\n" + "\vmax_retries\x18\t \x01(\x05R\n" +
"maxRetries\x1a;\n" + "maxRetries\x1a;\n" +
"\rMetadataEntry\x12\x10\n" + "\rMetadataEntry\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\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 // SendNotificationRequest is the REST API request for sending a notification
type SendNotificationRequest struct { type SendNotificationRequest struct {
Type string `json:"type"` Type string `json:"type"`
Account string `json:"account,omitempty"` // Optional account name for multi-account configs
Priority int `json:"priority,omitempty"` Priority int `json:"priority,omitempty"`
Subject string `json:"subject"` Subject string `json:"subject"`
Body string `json:"body"` Body string `json:"body"`
@@ -47,6 +48,7 @@ func (r *SendNotificationRequest) ToNotification() *domain.Notification {
return &domain.Notification{ return &domain.Notification{
ID: uuid.New().String(), ID: uuid.New().String(),
Type: domain.NotificationType(r.Type), Type: domain.NotificationType(r.Type),
Account: r.Account,
Priority: domain.Priority(r.Priority), Priority: domain.Priority(r.Priority),
Status: domain.StatusPending, Status: domain.StatusPending,
Subject: r.Subject, Subject: r.Subject,
@@ -79,6 +81,7 @@ type SendBatchNotificationsResponse struct {
type Notification struct { type Notification struct {
ID string `json:"id"` ID string `json:"id"`
Type string `json:"type"` Type string `json:"type"`
Account string `json:"account,omitempty"`
Priority int `json:"priority"` Priority int `json:"priority"`
Status string `json:"status"` Status string `json:"status"`
Subject string `json:"subject"` Subject string `json:"subject"`
@@ -98,6 +101,7 @@ func NotificationFromDomain(n *domain.Notification) Notification {
return Notification{ return Notification{
ID: n.ID, ID: n.ID,
Type: string(n.Type), Type: string(n.Type),
Account: n.Account,
Priority: int(n.Priority), Priority: int(n.Priority),
Status: string(n.Status), Status: string(n.Status),
Subject: n.Subject, Subject: n.Subject,
+36 -21
View File
@@ -69,8 +69,8 @@ func main() {
logger.Infof("Supported notification types: %v", factory.SupportedTypes()) logger.Infof("Supported notification types: %v", factory.SupportedTypes())
// Create notification service // Create notification service (pass config as account resolver)
svc := service.NewNotificationService(factory, q, cfg.Queue.WorkerCount) svc := service.NewNotificationService(factory, q, cfg.Queue.WorkerCount, cfg)
// Start workers // Start workers
if err := svc.Start(ctx); err != nil { if err := svc.Start(ctx); err != nil {
@@ -132,45 +132,60 @@ func main() {
func registerNotifiers(cfg *config.Config, factory *notifier.Factory, logger *logging.Logger) { func registerNotifiers(cfg *config.Config, factory *notifier.Factory, logger *logging.Logger) {
if cfg.Notifiers.Stdout { if cfg.Notifiers.Stdout {
stdoutNotifier := notifier.NewStdoutNotifier() 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.Fatalf("Failed to register stdout notifier: %v", err)
} }
logger.Info("Registered stdout notifier") logger.Info("Registered stdout notifier")
} }
if cfg.Notifiers.SMTP != nil { // Register SMTP notifiers (now supports multiple accounts)
smtpNotifier, err := notifier.NewSMTPNotifier(cfg.Notifiers.SMTP) for accountName, smtpConfig := range cfg.Notifiers.SMTP {
smtpNotifier, err := notifier.NewSMTPNotifier(smtpConfig)
if err != nil { 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 { } else {
if err := factory.RegisterNotifier(domain.TypeEmail, smtpNotifier); err != nil { if err := factory.RegisterNotifier(domain.TypeEmail, accountName, smtpNotifier); err != nil {
logger.Fatalf("Failed to register SMTP notifier: %v", err) 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 { // Register Slack notifiers (now supports multiple accounts)
slackNotifier, err := notifier.NewSlackNotifier(cfg.Notifiers.Slack) for accountName, slackConfig := range cfg.Notifiers.Slack {
slackNotifier, err := notifier.NewSlackNotifier(slackConfig)
if err != nil { 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 { } else {
if err := factory.RegisterNotifier(domain.TypeSlack, slackNotifier); err != nil { if err := factory.RegisterNotifier(domain.TypeSlack, accountName, slackNotifier); err != nil {
logger.Fatalf("Failed to register Slack notifier: %v", err) 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 { // Register Ntfy notifiers (now supports multiple accounts)
ntfyNotifier, err := notifier.NewNtfyNotifier(cfg.Notifiers.Ntfy) for accountName, ntfyConfig := range cfg.Notifiers.Ntfy {
ntfyNotifier, err := notifier.NewNtfyNotifier(ntfyConfig)
if err != nil { 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 { } else {
if err := factory.RegisterNotifier(domain.TypeNtfy, ntfyNotifier); err != nil { if err := factory.RegisterNotifier(domain.TypeNtfy, accountName, ntfyNotifier); err != nil {
logger.Fatalf("Failed to register Ntfy notifier: %v", err) 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) # Enable stdout notifier (useful for development/debugging)
stdout: true stdout: true
# SMTP email configuration # SMTP email configuration (supports multiple accounts)
smtp: smtp:
host: "smtp.gmail.com" # Personal email account (marked as default)
port: 587 personal:
username: "your-email@gmail.com" host: "smtp.gmail.com"
password: "your-app-password" port: 587
from: "notifications@yourservice.com" username: "your-personal@gmail.com"
use_tls: true 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: slack:
webhook_url: "https://hooks.slack.com/services/YOUR/WEBHOOK/URL" # Main workspace (marked as default)
# token: "xoxb-your-bot-token" # Alternative to webhook main:
# channel: "#notifications" # Default channel webhook_url: "https://hooks.slack.com/services/YOUR/WEBHOOK/URL"
username: "Notifier Bot" username: "Notifier Bot"
icon_emoji: ":bell:" icon_emoji: ":bell:"
# Channel-specific webhooks default: true # This workspace will be used if no account is specified
# webhooks:
# "#alerts": "https://hooks.slack.com/services/ALERTS/WEBHOOK"
# "#monitoring": "https://hooks.slack.com/services/MONITORING/WEBHOOK"
# 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: 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) # Private ntfy server example
# Supports both access tokens (tk_...) and publish tokens # private:
# token: "tk_your_access_token" # server_url: "https://ntfy.mycompany.com"
# username: "your-username"
# Basic authentication (alternative to token) # password: "your-password"
# username: "your-username" # default_topic: "company-notifications"
# password: "your-password" # insecure_skip_verify: false # Set to true for self-signed certs
# 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
logging: logging:
level: "info" # Options: debug, info, warn, error 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 // NotifiersConfig contains configuration for all notifier types
type NotifiersConfig struct { type NotifiersConfig struct {
SMTP *notifier.SMTPConfig `mapstructure:"smtp"` SMTP map[string]*notifier.SMTPConfig `mapstructure:"smtp"`
Slack *notifier.SlackConfig `mapstructure:"slack"` Slack map[string]*notifier.SlackConfig `mapstructure:"slack"`
Ntfy *notifier.NtfyConfig `mapstructure:"ntfy"` Ntfy map[string]*notifier.NtfyConfig `mapstructure:"ntfy"`
Stdout bool `mapstructure:"stdout"` // Enable stdout notifier Stdout bool `mapstructure:"stdout"` // Enable stdout notifier
} }
// LoggingConfig contains logging configuration // LoggingConfig contains logging configuration
@@ -185,9 +185,9 @@ func (c *Config) Validate() error {
// HasAnyNotifier checks if at least one notifier is configured // HasAnyNotifier checks if at least one notifier is configured
func (c *Config) HasAnyNotifier() bool { func (c *Config) HasAnyNotifier() bool {
return c.Notifiers.Stdout || return c.Notifiers.Stdout ||
c.Notifiers.SMTP != nil || len(c.Notifiers.SMTP) > 0 ||
c.Notifiers.Slack != nil || len(c.Notifiers.Slack) > 0 ||
c.Notifiers.Ntfy != nil len(c.Notifiers.Ntfy) > 0
} }
// GetEnabledNotifiers returns a list of enabled notifier types // GetEnabledNotifiers returns a list of enabled notifier types
@@ -197,15 +197,52 @@ func (c *Config) GetEnabledNotifiers() []domain.NotificationType {
if c.Notifiers.Stdout { if c.Notifiers.Stdout {
enabled = append(enabled, domain.TypeStdout) enabled = append(enabled, domain.TypeStdout)
} }
if c.Notifiers.SMTP != nil { if len(c.Notifiers.SMTP) > 0 {
enabled = append(enabled, domain.TypeEmail) enabled = append(enabled, domain.TypeEmail)
} }
if c.Notifiers.Slack != nil { if len(c.Notifiers.Slack) > 0 {
enabled = append(enabled, domain.TypeSlack) enabled = append(enabled, domain.TypeSlack)
} }
if c.Notifiers.Ntfy != nil { if len(c.Notifiers.Ntfy) > 0 {
enabled = append(enabled, domain.TypeNtfy) enabled = append(enabled, domain.TypeNtfy)
} }
return enabled 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 specifies which notifier should handle this notification
Type NotificationType `json:"type"` 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 determines urgency and retry behavior
Priority Priority `json:"priority"` Priority Priority `json:"priority"`
+7 -3
View File
@@ -21,14 +21,18 @@ type Notifier interface {
// NotifierFactory creates notifier instances based on configuration // NotifierFactory creates notifier instances based on configuration
type NotifierFactory interface { type NotifierFactory interface {
// Create creates a notifier for the given type // Create creates a notifier for the given type and account
Create(notificationType NotificationType) (Notifier, error) // 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 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 returns all supported notification types
SupportedTypes() []NotificationType 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 // 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 // Factory creates and manages notifier instances
type Factory struct { type Factory struct {
notifiers map[domain.NotificationType]domain.Notifier // Map of "type:account" -> notifier instance
notifiers map[string]domain.Notifier
mu sync.RWMutex mu sync.RWMutex
} }
// NewFactory creates a new notifier factory // NewFactory creates a new notifier factory
func NewFactory() *Factory { func NewFactory() *Factory {
return &Factory{ return &Factory{
notifiers: make(map[domain.NotificationType]domain.Notifier), notifiers: make(map[string]domain.Notifier),
} }
} }
// Create creates a notifier for the given type // makeKey creates a compound key from notification type and account
func (f *Factory) Create(notificationType domain.NotificationType) (domain.Notifier, error) { 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() f.mu.RLock()
defer f.mu.RUnlock() defer f.mu.RUnlock()
notifier, exists := f.notifiers[notificationType] key := makeKey(notificationType, account)
notifier, exists := f.notifiers[key]
if !exists { 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) 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 // 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() f.mu.Lock()
defer f.mu.Unlock() 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) return fmt.Errorf("notifier already registered for type: %s", notificationType)
} }
f.notifiers[notificationType] = notifier f.notifiers[key] = notifier
return nil return nil
} }
// SupportedTypes returns all supported notification types // SupportedTypes returns all supported notification types (unique types only)
func (f *Factory) SupportedTypes() []domain.NotificationType { func (f *Factory) SupportedTypes() []domain.NotificationType {
f.mu.RLock() f.mu.RLock()
defer f.mu.RUnlock() defer f.mu.RUnlock()
types := make([]domain.NotificationType, 0, len(f.notifiers)) typeMap := make(map[domain.NotificationType]bool)
for t := range f.notifiers { 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) types = append(types, t)
} }
return types 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 // BaseNotifier provides common functionality for all notifiers
type BaseNotifier struct { type BaseNotifier struct {
notificationType domain.NotificationType 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 skips TLS verification (for self-hosted servers with self-signed certs)
InsecureSkipVerify bool `mapstructure:"insecure_skip_verify"` InsecureSkipVerify bool `mapstructure:"insecure_skip_verify"`
// Default marks this instance as default
Default bool `mapstructure:"default"`
} }
// NtfyNotifier sends notifications via ntfy.sh // NtfyNotifier sends notifications via ntfy.sh
+1
View File
@@ -19,6 +19,7 @@ type SlackConfig struct {
Username string `mapstructure:"username"` Username string `mapstructure:"username"`
IconEmoji string `mapstructure:"icon_emoji"` IconEmoji string `mapstructure:"icon_emoji"`
Webhooks map[string]string `mapstructure:"webhooks"` // Channel-specific webhooks Webhooks map[string]string `mapstructure:"webhooks"` // Channel-specific webhooks
Default bool `mapstructure:"default"` // Mark this instance as default
} }
// SlackNotifier sends notifications to Slack // SlackNotifier sends notifications to Slack
+1
View File
@@ -18,6 +18,7 @@ type SMTPConfig struct {
Password string `mapstructure:"password"` Password string `mapstructure:"password"`
From string `mapstructure:"from"` From string `mapstructure:"from"`
UseTLS bool `mapstructure:"use_tls"` UseTLS bool `mapstructure:"use_tls"`
Default bool `mapstructure:"default"` // Mark this instance as default
} }
// SMTPNotifier sends notifications via email using SMTP // SMTPNotifier sends notifications via email using SMTP
+27 -14
View File
@@ -9,29 +9,36 @@ import (
"github.com/igodwin/notifier/internal/domain" "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 // NotificationService implements the domain.NotificationService interface
type NotificationService struct { type NotificationService struct {
factory domain.NotifierFactory factory domain.NotifierFactory
queue domain.Queue queue domain.Queue
notifications map[string]*domain.Notification accountResolver AccountResolver
mu sync.RWMutex notifications map[string]*domain.Notification
workerCount int mu sync.RWMutex
stopChan chan struct{} workerCount int
wg sync.WaitGroup stopChan chan struct{}
wg sync.WaitGroup
} }
// NewNotificationService creates a new notification service // 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 { if workerCount <= 0 {
workerCount = 10 workerCount = 10
} }
return &NotificationService{ return &NotificationService{
factory: factory, factory: factory,
queue: queue, queue: queue,
notifications: make(map[string]*domain.Notification), accountResolver: accountResolver,
workerCount: workerCount, notifications: make(map[string]*domain.Notification),
stopChan: make(chan struct{}), 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) { func (s *NotificationService) processNotification(ctx context.Context, msg *domain.QueueMessage) {
notification := msg.Notification 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 // Get the appropriate notifier
notifier, err := s.factory.Create(notification.Type) notifier, err := s.factory.Create(notification.Type, account)
if err != nil { if err != nil {
notification.Status = domain.StatusFailed notification.Status = domain.StatusFailed
notification.LastError = fmt.Sprintf("failed to create notifier: %v", err) notification.LastError = fmt.Sprintf("failed to create notifier: %v", err)