Enhance notification APIs with HTML email support, CC/BCC, and structured logging
Add comprehensive improvements across REST and gRPC APIs: - Add structured logging for all notification operations - Implement HTML email support with multipart/alternative MIME - Add CC and BCC recipient support for email notifications - Add GetNotifiers endpoint to query available notifier configurations - Support configurable From name in SMTP configuration - Auto-detect content type (text vs HTML) in notification bodies - Improve error handling and validation across all endpoints 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+107
-9
@@ -4,8 +4,10 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
pb "github.com/igodwin/notifier/api/grpc/pb"
|
||||
"github.com/igodwin/notifier/internal/domain"
|
||||
"github.com/igodwin/notifier/internal/logging"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
@@ -13,12 +15,14 @@ import (
|
||||
type NotifierHandler struct {
|
||||
pb.UnimplementedNotifierServiceServer
|
||||
service domain.NotificationService
|
||||
logger *logging.Logger
|
||||
}
|
||||
|
||||
// NewNotifierHandler creates a new gRPC handler
|
||||
func NewNotifierHandler(svc domain.NotificationService) *NotifierHandler {
|
||||
func NewNotifierHandler(svc domain.NotificationService, logger *logging.Logger) *NotifierHandler {
|
||||
return &NotifierHandler{
|
||||
service: svc,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,19 +40,36 @@ func (h *NotifierHandler) HealthCheck(ctx context.Context, req *pb.HealthCheckRe
|
||||
|
||||
// SendNotification sends a single notification
|
||||
func (h *NotifierHandler) SendNotification(ctx context.Context, req *pb.SendNotificationRequest) (*pb.SendNotificationResponse, error) {
|
||||
// Log incoming request
|
||||
h.logger.Infof("gRPC: Received notification request - type=%s, account=%s, recipients=%d, subject=%s",
|
||||
req.Type, req.Account, len(req.Recipients), req.Subject)
|
||||
|
||||
// Convert proto notification type to domain type
|
||||
notifType := convertProtoTypeToDomain(req.Type)
|
||||
|
||||
// Set default max retries if not specified
|
||||
maxRetries := int(req.MaxRetries)
|
||||
if maxRetries == 0 {
|
||||
maxRetries = 3 // Default
|
||||
}
|
||||
|
||||
// Convert content type, defaulting to text
|
||||
contentType := convertProtoContentTypeToDomain(req.ContentType)
|
||||
|
||||
// Build notification
|
||||
notification := &domain.Notification{
|
||||
Type: notifType,
|
||||
Account: req.Account,
|
||||
Priority: domain.Priority(req.Priority),
|
||||
Subject: req.Subject,
|
||||
Body: req.Body,
|
||||
Recipients: req.Recipients,
|
||||
Metadata: convertStringMapToInterface(req.Metadata),
|
||||
MaxRetries: int(req.MaxRetries),
|
||||
ID: uuid.New().String(),
|
||||
Type: notifType,
|
||||
Account: req.Account,
|
||||
Priority: domain.Priority(req.Priority),
|
||||
Subject: req.Subject,
|
||||
Body: req.Body,
|
||||
ContentType: contentType,
|
||||
Recipients: req.Recipients,
|
||||
CC: req.Cc,
|
||||
BCC: req.Bcc,
|
||||
Metadata: convertStringMapToInterface(req.Metadata),
|
||||
MaxRetries: maxRetries,
|
||||
}
|
||||
|
||||
if req.ScheduledFor != nil {
|
||||
@@ -59,6 +80,8 @@ func (h *NotifierHandler) SendNotification(ctx context.Context, req *pb.SendNoti
|
||||
// Send notification
|
||||
result, err := h.service.Send(ctx, notification)
|
||||
if err != nil {
|
||||
h.logger.Errorf("gRPC: Failed to send notification - type=%s, account=%s, error=%v",
|
||||
req.Type, req.Account, err)
|
||||
return &pb.SendNotificationResponse{
|
||||
Result: &pb.NotificationResult{
|
||||
Success: false,
|
||||
@@ -67,6 +90,10 @@ func (h *NotifierHandler) SendNotification(ctx context.Context, req *pb.SendNoti
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Log success
|
||||
h.logger.Infof("gRPC: Notification queued successfully - id=%s, type=%s, recipients=%d",
|
||||
result.NotificationID, req.Type, len(req.Recipients))
|
||||
|
||||
// Convert result to proto
|
||||
return &pb.SendNotificationResponse{
|
||||
Result: &pb.NotificationResult{
|
||||
@@ -80,7 +107,10 @@ func (h *NotifierHandler) SendNotification(ctx context.Context, req *pb.SendNoti
|
||||
|
||||
// SendBatchNotifications sends multiple notifications
|
||||
func (h *NotifierHandler) SendBatchNotifications(ctx context.Context, req *pb.SendBatchNotificationsRequest) (*pb.SendBatchNotificationsResponse, error) {
|
||||
h.logger.Infof("gRPC: Received batch notification request - count=%d", len(req.Notifications))
|
||||
|
||||
var results []*pb.NotificationResult
|
||||
successCount := 0
|
||||
|
||||
for _, notifReq := range req.Notifications {
|
||||
resp, err := h.SendNotification(ctx, notifReq)
|
||||
@@ -91,9 +121,15 @@ func (h *NotifierHandler) SendBatchNotifications(ctx context.Context, req *pb.Se
|
||||
})
|
||||
} else {
|
||||
results = append(results, resp.Result)
|
||||
if resp.Result.Success {
|
||||
successCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
h.logger.Infof("gRPC: Batch notification completed - total=%d, successful=%d, failed=%d",
|
||||
len(req.Notifications), successCount, len(req.Notifications)-successCount)
|
||||
|
||||
return &pb.SendBatchNotificationsResponse{
|
||||
Results: results,
|
||||
}, nil
|
||||
@@ -187,6 +223,31 @@ func (h *NotifierHandler) GetStats(ctx context.Context, req *pb.GetStatsRequest)
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetNotifiers returns information about available notifiers
|
||||
func (h *NotifierHandler) GetNotifiers(ctx context.Context, req *pb.GetNotifiersRequest) (*pb.GetNotifiersResponse, error) {
|
||||
h.logger.Infof("gRPC: Received request for available notifiers")
|
||||
|
||||
notifiers, err := h.service.GetNotifiers(ctx)
|
||||
if err != nil {
|
||||
h.logger.Errorf("gRPC: Failed to get notifiers - error=%v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Convert domain notifiers to proto notifiers
|
||||
protoNotifiers := make([]*pb.NotifierInfo, 0, len(notifiers.Notifiers))
|
||||
for _, notifier := range notifiers.Notifiers {
|
||||
protoNotifiers = append(protoNotifiers, &pb.NotifierInfo{
|
||||
Type: convertDomainTypeToProto(notifier.Type),
|
||||
Accounts: notifier.Accounts,
|
||||
DefaultAccount: notifier.DefaultAccount,
|
||||
})
|
||||
}
|
||||
|
||||
return &pb.GetNotifiersResponse{
|
||||
Notifiers: protoNotifiers,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Helper functions to convert between proto and domain types
|
||||
|
||||
// convertStringMapToInterface converts proto's map[string]string to domain's map[string]interface{}
|
||||
@@ -230,6 +291,43 @@ func convertProtoTypeToDomain(protoType pb.NotificationType) domain.Notification
|
||||
}
|
||||
}
|
||||
|
||||
func convertDomainTypeToProto(domainType domain.NotificationType) pb.NotificationType {
|
||||
switch domainType {
|
||||
case domain.TypeEmail:
|
||||
return pb.NotificationType_NOTIFICATION_TYPE_EMAIL
|
||||
case domain.TypeSlack:
|
||||
return pb.NotificationType_NOTIFICATION_TYPE_SLACK
|
||||
case domain.TypeNtfy:
|
||||
return pb.NotificationType_NOTIFICATION_TYPE_NTFY
|
||||
case domain.TypeStdout:
|
||||
return pb.NotificationType_NOTIFICATION_TYPE_STDOUT
|
||||
default:
|
||||
return pb.NotificationType_NOTIFICATION_TYPE_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
|
||||
func convertProtoContentTypeToDomain(protoType pb.ContentType) domain.ContentType {
|
||||
switch protoType {
|
||||
case pb.ContentType_CONTENT_TYPE_HTML:
|
||||
return domain.ContentTypeHTML
|
||||
case pb.ContentType_CONTENT_TYPE_TEXT:
|
||||
return domain.ContentTypeText
|
||||
default:
|
||||
return domain.ContentTypeText // Default to text
|
||||
}
|
||||
}
|
||||
|
||||
func convertDomainContentTypeToProto(domainType domain.ContentType) pb.ContentType {
|
||||
switch domainType {
|
||||
case domain.ContentTypeHTML:
|
||||
return pb.ContentType_CONTENT_TYPE_HTML
|
||||
case domain.ContentTypeText:
|
||||
return pb.ContentType_CONTENT_TYPE_TEXT
|
||||
default:
|
||||
return pb.ContentType_CONTENT_TYPE_TEXT
|
||||
}
|
||||
}
|
||||
|
||||
func convertDomainToProtoType(domainType domain.NotificationType) pb.NotificationType {
|
||||
switch domainType {
|
||||
case domain.TypeEmail:
|
||||
|
||||
@@ -29,6 +29,9 @@ service NotifierService {
|
||||
// GetStats returns notification statistics
|
||||
rpc GetStats(GetStatsRequest) returns (GetStatsResponse);
|
||||
|
||||
// GetNotifiers returns information about available notifiers
|
||||
rpc GetNotifiers(GetNotifiersRequest) returns (GetNotifiersResponse);
|
||||
|
||||
// HealthCheck verifies the service is operational
|
||||
rpc HealthCheck(HealthCheckRequest) returns (HealthCheckResponse);
|
||||
}
|
||||
@@ -51,6 +54,13 @@ enum Priority {
|
||||
PRIORITY_CRITICAL = 4;
|
||||
}
|
||||
|
||||
// ContentType defines the format of the notification body
|
||||
enum ContentType {
|
||||
CONTENT_TYPE_UNSPECIFIED = 0;
|
||||
CONTENT_TYPE_TEXT = 1;
|
||||
CONTENT_TYPE_HTML = 2;
|
||||
}
|
||||
|
||||
// NotificationStatus represents the state of a notification
|
||||
enum NotificationStatus {
|
||||
NOTIFICATION_STATUS_UNSPECIFIED = 0;
|
||||
@@ -71,7 +81,10 @@ message Notification {
|
||||
NotificationStatus status = 5;
|
||||
string subject = 6;
|
||||
string body = 7;
|
||||
ContentType content_type = 18; // Format of the body (text or html)
|
||||
repeated string recipients = 8;
|
||||
repeated string cc = 16; // Carbon copy recipients (email only)
|
||||
repeated string bcc = 17; // Blind carbon copy recipients (email only)
|
||||
map<string, string> metadata = 9;
|
||||
google.protobuf.Timestamp created_at = 10;
|
||||
google.protobuf.Timestamp scheduled_for = 11;
|
||||
@@ -98,7 +111,10 @@ message SendNotificationRequest {
|
||||
Priority priority = 3;
|
||||
string subject = 4;
|
||||
string body = 5;
|
||||
ContentType content_type = 12; // Format of the body (text or html) - auto-detected if not specified
|
||||
repeated string recipients = 6;
|
||||
repeated string cc = 10; // Carbon copy recipients (email only)
|
||||
repeated string bcc = 11; // Blind carbon copy recipients (email only)
|
||||
map<string, string> metadata = 7;
|
||||
google.protobuf.Timestamp scheduled_for = 8;
|
||||
int32 max_retries = 9;
|
||||
@@ -187,6 +203,21 @@ message GetStatsResponse {
|
||||
double average_latency_ms = 7;
|
||||
}
|
||||
|
||||
// GetNotifiersRequest requests available notifiers
|
||||
message GetNotifiersRequest {}
|
||||
|
||||
// NotifierInfo contains information about a configured notifier type
|
||||
message NotifierInfo {
|
||||
NotificationType type = 1;
|
||||
repeated string accounts = 2;
|
||||
string default_account = 3;
|
||||
}
|
||||
|
||||
// GetNotifiersResponse returns available notifiers
|
||||
message GetNotifiersResponse {
|
||||
repeated NotifierInfo notifiers = 1;
|
||||
}
|
||||
|
||||
// HealthCheckRequest requests health status
|
||||
message HealthCheckRequest {}
|
||||
|
||||
|
||||
+46
-1
@@ -8,17 +8,20 @@ import (
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/igodwin/notifier/internal/domain"
|
||||
"github.com/igodwin/notifier/internal/logging"
|
||||
)
|
||||
|
||||
// Handler handles REST API requests
|
||||
type Handler struct {
|
||||
service domain.NotificationService
|
||||
logger *logging.Logger
|
||||
}
|
||||
|
||||
// NewHandler creates a new REST handler
|
||||
func NewHandler(service domain.NotificationService) *Handler {
|
||||
func NewHandler(service domain.NotificationService, logger *logging.Logger) *Handler {
|
||||
return &Handler{
|
||||
service: service,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,12 +29,14 @@ func NewHandler(service domain.NotificationService) *Handler {
|
||||
func (h *Handler) SendNotification(w http.ResponseWriter, r *http.Request) {
|
||||
var req SendNotificationRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
h.logger.Errorf("REST: Failed to decode request body - error=%v", err)
|
||||
respondError(w, http.StatusBadRequest, "invalid request body", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate request
|
||||
if err := req.Validate(); err != nil {
|
||||
h.logger.Errorf("REST: Request validation failed - error=%v", err)
|
||||
respondError(w, http.StatusBadRequest, "validation failed", err)
|
||||
return
|
||||
}
|
||||
@@ -39,13 +44,23 @@ func (h *Handler) SendNotification(w http.ResponseWriter, r *http.Request) {
|
||||
// Convert to domain notification
|
||||
notification := req.ToNotification()
|
||||
|
||||
// Log incoming request
|
||||
h.logger.Infof("REST: Received notification request - type=%s, account=%s, recipients=%d, subject=%s",
|
||||
notification.Type, notification.Account, len(notification.Recipients), notification.Subject)
|
||||
|
||||
// Send notification
|
||||
result, err := h.service.Send(r.Context(), notification)
|
||||
if err != nil {
|
||||
h.logger.Errorf("REST: Failed to send notification - type=%s, account=%s, error=%v",
|
||||
notification.Type, notification.Account, err)
|
||||
respondError(w, http.StatusInternalServerError, "failed to send notification", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Log success
|
||||
h.logger.Infof("REST: Notification queued successfully - id=%s, type=%s, recipients=%d",
|
||||
result.NotificationID, notification.Type, len(notification.Recipients))
|
||||
|
||||
respondJSON(w, http.StatusAccepted, SendNotificationResponse{
|
||||
Result: NotificationResultFromDomain(result),
|
||||
})
|
||||
@@ -55,14 +70,18 @@ func (h *Handler) SendNotification(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *Handler) SendBatchNotifications(w http.ResponseWriter, r *http.Request) {
|
||||
var req SendBatchNotificationsRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
h.logger.Errorf("REST: Failed to decode batch request body - error=%v", err)
|
||||
respondError(w, http.StatusBadRequest, "invalid request body", err)
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Infof("REST: Received batch notification request - count=%d", len(req.Notifications))
|
||||
|
||||
// Validate and convert to domain notifications
|
||||
notifications := make([]*domain.Notification, 0, len(req.Notifications))
|
||||
for _, notifReq := range req.Notifications {
|
||||
if err := notifReq.Validate(); err != nil {
|
||||
h.logger.Errorf("REST: Batch request validation failed - error=%v", err)
|
||||
respondError(w, http.StatusBadRequest, "validation failed", err)
|
||||
return
|
||||
}
|
||||
@@ -72,10 +91,22 @@ func (h *Handler) SendBatchNotifications(w http.ResponseWriter, r *http.Request)
|
||||
// Send batch
|
||||
results, err := h.service.SendBatch(r.Context(), notifications)
|
||||
if err != nil {
|
||||
h.logger.Errorf("REST: Failed to send batch notifications - error=%v", err)
|
||||
respondError(w, http.StatusInternalServerError, "failed to send batch notifications", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Count successes
|
||||
successCount := 0
|
||||
for _, result := range results {
|
||||
if result.Success {
|
||||
successCount++
|
||||
}
|
||||
}
|
||||
|
||||
h.logger.Infof("REST: Batch notification completed - total=%d, successful=%d, failed=%d",
|
||||
len(notifications), successCount, len(notifications)-successCount)
|
||||
|
||||
// Convert results
|
||||
apiResults := make([]NotificationResult, 0, len(results))
|
||||
for _, result := range results {
|
||||
@@ -166,6 +197,20 @@ func (h *Handler) GetStats(w http.ResponseWriter, r *http.Request) {
|
||||
respondJSON(w, http.StatusOK, stats)
|
||||
}
|
||||
|
||||
// GetNotifiers handles GET /api/v1/notifiers
|
||||
func (h *Handler) GetNotifiers(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Infof("REST: Received request for available notifiers")
|
||||
|
||||
notifiers, err := h.service.GetNotifiers(r.Context())
|
||||
if err != nil {
|
||||
h.logger.Errorf("REST: Failed to get notifiers - error=%v", err)
|
||||
respondError(w, http.StatusInternalServerError, "failed to get notifiers", err)
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, notifiers)
|
||||
}
|
||||
|
||||
// HealthCheck handles GET /health
|
||||
func (h *Handler) HealthCheck(w http.ResponseWriter, r *http.Request) {
|
||||
respondJSON(w, http.StatusOK, map[string]interface{}{
|
||||
|
||||
+6
-2
@@ -5,11 +5,12 @@ import (
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/igodwin/notifier/internal/domain"
|
||||
"github.com/igodwin/notifier/internal/logging"
|
||||
)
|
||||
|
||||
// NewRouter creates a new HTTP router with all routes configured
|
||||
func NewRouter(service domain.NotificationService) *mux.Router {
|
||||
handler := NewHandler(service)
|
||||
func NewRouter(service domain.NotificationService, logger *logging.Logger) *mux.Router {
|
||||
handler := NewHandler(service, logger)
|
||||
router := mux.NewRouter()
|
||||
|
||||
// API v1 routes
|
||||
@@ -26,6 +27,9 @@ func NewRouter(service domain.NotificationService) *mux.Router {
|
||||
// Stats route
|
||||
v1.HandleFunc("/stats", handler.GetStats).Methods(http.MethodGet)
|
||||
|
||||
// Notifiers route
|
||||
v1.HandleFunc("/notifiers", handler.GetNotifiers).Methods(http.MethodGet)
|
||||
|
||||
// Health check route
|
||||
router.HandleFunc("/health", handler.HealthCheck).Methods(http.MethodGet)
|
||||
|
||||
|
||||
+23
-2
@@ -15,7 +15,10 @@ type SendNotificationRequest struct {
|
||||
Priority int `json:"priority,omitempty"`
|
||||
Subject string `json:"subject"`
|
||||
Body string `json:"body"`
|
||||
ContentType string `json:"content_type,omitempty"` // "text" or "html" - auto-detected if not specified
|
||||
Recipients []string `json:"recipients"`
|
||||
CC []string `json:"cc,omitempty"` // Carbon copy recipients (email only)
|
||||
BCC []string `json:"bcc,omitempty"` // Blind carbon copy recipients (email only)
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
ScheduledFor *time.Time `json:"scheduled_for,omitempty"`
|
||||
MaxRetries int `json:"max_retries,omitempty"`
|
||||
@@ -27,8 +30,11 @@ func (r *SendNotificationRequest) Validate() error {
|
||||
return fmt.Errorf("type is required")
|
||||
}
|
||||
|
||||
if len(r.Recipients) == 0 {
|
||||
return fmt.Errorf("at least one recipient is required")
|
||||
// For email, allow BCC-only (at least one recipient in To, CC, or BCC)
|
||||
// For other types, require Recipients
|
||||
totalRecipients := len(r.Recipients) + len(r.CC) + len(r.BCC)
|
||||
if totalRecipients == 0 {
|
||||
return fmt.Errorf("at least one recipient is required (recipients, cc, or bcc)")
|
||||
}
|
||||
|
||||
if r.Body == "" {
|
||||
@@ -45,6 +51,12 @@ func (r *SendNotificationRequest) ToNotification() *domain.Notification {
|
||||
maxRetries = 3 // Default
|
||||
}
|
||||
|
||||
// Convert content type, defaulting to text
|
||||
contentType := domain.ContentType(r.ContentType)
|
||||
if contentType == "" {
|
||||
contentType = domain.ContentTypeText
|
||||
}
|
||||
|
||||
return &domain.Notification{
|
||||
ID: uuid.New().String(),
|
||||
Type: domain.NotificationType(r.Type),
|
||||
@@ -53,7 +65,10 @@ func (r *SendNotificationRequest) ToNotification() *domain.Notification {
|
||||
Status: domain.StatusPending,
|
||||
Subject: r.Subject,
|
||||
Body: r.Body,
|
||||
ContentType: contentType,
|
||||
Recipients: r.Recipients,
|
||||
CC: r.CC,
|
||||
BCC: r.BCC,
|
||||
Metadata: r.Metadata,
|
||||
CreatedAt: time.Now(),
|
||||
ScheduledFor: r.ScheduledFor,
|
||||
@@ -86,7 +101,10 @@ type Notification struct {
|
||||
Status string `json:"status"`
|
||||
Subject string `json:"subject"`
|
||||
Body string `json:"body"`
|
||||
ContentType string `json:"content_type,omitempty"`
|
||||
Recipients []string `json:"recipients"`
|
||||
CC []string `json:"cc,omitempty"`
|
||||
BCC []string `json:"bcc,omitempty"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ScheduledFor *time.Time `json:"scheduled_for,omitempty"`
|
||||
@@ -106,7 +124,10 @@ func NotificationFromDomain(n *domain.Notification) Notification {
|
||||
Status: string(n.Status),
|
||||
Subject: n.Subject,
|
||||
Body: n.Body,
|
||||
ContentType: string(n.ContentType),
|
||||
Recipients: n.Recipients,
|
||||
CC: n.CC,
|
||||
BCC: n.BCC,
|
||||
Metadata: n.Metadata,
|
||||
CreatedAt: n.CreatedAt,
|
||||
ScheduledFor: n.ScheduledFor,
|
||||
|
||||
Reference in New Issue
Block a user