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:
@@ -268,13 +268,14 @@ func (c *Config) Sanitize() map[string]interface{} {
|
||||
smtpAccounts := make(map[string]interface{})
|
||||
for name, cfg := range c.Notifiers.SMTP {
|
||||
smtpAccounts[name] = map[string]interface{}{
|
||||
"host": cfg.Host,
|
||||
"port": cfg.Port,
|
||||
"username": cfg.Username,
|
||||
"password": "***REDACTED***",
|
||||
"from": cfg.From,
|
||||
"use_tls": cfg.UseTLS,
|
||||
"default": cfg.Default,
|
||||
"host": cfg.Host,
|
||||
"port": cfg.Port,
|
||||
"username": cfg.Username,
|
||||
"password": "***REDACTED***",
|
||||
"from": cfg.From,
|
||||
"from_name": cfg.FromName,
|
||||
"use_tls": cfg.UseTLS,
|
||||
"default": cfg.Default,
|
||||
}
|
||||
}
|
||||
notifiers["smtp"] = smtpAccounts
|
||||
|
||||
@@ -24,6 +24,14 @@ const (
|
||||
TypeStdout NotificationType = "stdout"
|
||||
)
|
||||
|
||||
// ContentType defines the format of the notification body
|
||||
type ContentType string
|
||||
|
||||
const (
|
||||
ContentTypeText ContentType = "text"
|
||||
ContentTypeHTML ContentType = "html"
|
||||
)
|
||||
|
||||
// NotificationStatus represents the current state of a notification
|
||||
type NotificationStatus string
|
||||
|
||||
@@ -60,9 +68,20 @@ type Notification struct {
|
||||
// Body is the main content of the notification
|
||||
Body string `json:"body"`
|
||||
|
||||
// ContentType specifies the format of the body (text or html)
|
||||
// Defaults to "text" if not specified. HTML is auto-detected if body starts with < or contains HTML tags.
|
||||
ContentType ContentType `json:"content_type,omitempty"`
|
||||
|
||||
// Recipients contains the target addresses (email, slack channel, ntfy topic, etc.)
|
||||
// For email: these are the "To" recipients
|
||||
Recipients []string `json:"recipients"`
|
||||
|
||||
// CC contains carbon copy recipients (email only, optional)
|
||||
CC []string `json:"cc,omitempty"`
|
||||
|
||||
// BCC contains blind carbon copy recipients (email only, optional)
|
||||
BCC []string `json:"bcc,omitempty"`
|
||||
|
||||
// Metadata contains additional provider-specific data
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
|
||||
|
||||
@@ -57,6 +57,9 @@ type NotificationService interface {
|
||||
|
||||
// GetStats returns notification statistics
|
||||
GetStats(ctx context.Context) (*NotificationStats, error)
|
||||
|
||||
// GetNotifiers returns information about available notifiers
|
||||
GetNotifiers(ctx context.Context) (*NotifiersResponse, error)
|
||||
}
|
||||
|
||||
// NotificationStats contains statistics about notification processing
|
||||
@@ -69,3 +72,15 @@ type NotificationStats struct {
|
||||
ByStatus map[string]int64 `json:"by_status"`
|
||||
AverageLatency float64 `json:"average_latency_ms"`
|
||||
}
|
||||
|
||||
// NotifierInfo contains information about a configured notifier type
|
||||
type NotifierInfo struct {
|
||||
Type NotificationType `json:"type"`
|
||||
Accounts []string `json:"accounts"`
|
||||
DefaultAccount string `json:"default_account"`
|
||||
}
|
||||
|
||||
// NotifiersResponse contains the list of available notifiers
|
||||
type NotifiersResponse struct {
|
||||
Notifiers []NotifierInfo `json:"notifiers"`
|
||||
}
|
||||
|
||||
@@ -72,14 +72,16 @@ func (f *Factory) SupportedTypes() []domain.NotificationType {
|
||||
|
||||
typeMap := make(map[domain.NotificationType]bool)
|
||||
for key := range f.notifiers {
|
||||
// Extract the type from the key (type:account)
|
||||
// Extract the type from the key (type:account or just type)
|
||||
var notifType domain.NotificationType
|
||||
if n, err := fmt.Sscanf(key, "%s:", ¬ifType); err == nil && n > 0 {
|
||||
typeMap[notifType] = true
|
||||
if colonIdx := findColon(key); colonIdx >= 0 {
|
||||
// Key format: "type:account"
|
||||
notifType = domain.NotificationType(key[:colonIdx])
|
||||
} else {
|
||||
// Backward compatibility: key might just be the type
|
||||
typeMap[domain.NotificationType(key)] = true
|
||||
// Key format: just "type" (backward compatibility)
|
||||
notifType = domain.NotificationType(key)
|
||||
}
|
||||
typeMap[notifType] = true
|
||||
}
|
||||
|
||||
types := make([]domain.NotificationType, 0, len(typeMap))
|
||||
@@ -90,6 +92,16 @@ func (f *Factory) SupportedTypes() []domain.NotificationType {
|
||||
return types
|
||||
}
|
||||
|
||||
// findColon finds the index of ':' in a string, returns -1 if not found
|
||||
func findColon(s string) int {
|
||||
for i, c := range s {
|
||||
if c == ':' {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// GetAccounts returns all registered accounts for a given notification type
|
||||
func (f *Factory) GetAccounts(notificationType domain.NotificationType) []string {
|
||||
f.mu.RLock()
|
||||
|
||||
+130
-9
@@ -2,8 +2,12 @@ package notifier
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"html"
|
||||
"net/smtp"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -17,6 +21,7 @@ type SMTPConfig struct {
|
||||
Username string `mapstructure:"username"`
|
||||
Password string `mapstructure:"password"`
|
||||
From string `mapstructure:"from"`
|
||||
FromName string `mapstructure:"from_name"` // Optional display name for From header
|
||||
UseTLS bool `mapstructure:"use_tls"`
|
||||
Default bool `mapstructure:"default"` // Mark this instance as default
|
||||
}
|
||||
@@ -63,8 +68,14 @@ func (s *SMTPNotifier) Send(ctx context.Context, notification *domain.Notificati
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Collect all recipients (To, CC, BCC) for validation
|
||||
allRecipients := make([]string, 0, len(notification.Recipients)+len(notification.CC)+len(notification.BCC))
|
||||
allRecipients = append(allRecipients, notification.Recipients...)
|
||||
allRecipients = append(allRecipients, notification.CC...)
|
||||
allRecipients = append(allRecipients, notification.BCC...)
|
||||
|
||||
// Validate email recipients
|
||||
for _, recipient := range notification.Recipients {
|
||||
for _, recipient := range allRecipients {
|
||||
if !strings.Contains(recipient, "@") {
|
||||
return &domain.NotificationResult{
|
||||
NotificationID: notification.ID,
|
||||
@@ -82,7 +93,8 @@ func (s *SMTPNotifier) Send(ctx context.Context, notification *domain.Notificati
|
||||
addr := fmt.Sprintf("%s:%d", s.config.Host, s.config.Port)
|
||||
auth := smtp.PlainAuth("", s.config.Username, s.config.Password, s.config.Host)
|
||||
|
||||
err := smtp.SendMail(addr, auth, s.config.From, notification.Recipients, []byte(message))
|
||||
// smtp.SendMail needs all recipients (To, CC, BCC) for actual delivery
|
||||
err := smtp.SendMail(addr, auth, s.config.From, allRecipients, []byte(message))
|
||||
if err != nil {
|
||||
return &domain.NotificationResult{
|
||||
NotificationID: notification.ID,
|
||||
@@ -109,21 +121,130 @@ func (s *SMTPNotifier) Send(ctx context.Context, notification *domain.Notificati
|
||||
func (s *SMTPNotifier) buildMessage(notification *domain.Notification) string {
|
||||
var builder strings.Builder
|
||||
|
||||
builder.WriteString(fmt.Sprintf("From: %s\r\n", s.config.From))
|
||||
builder.WriteString(fmt.Sprintf("To: %s\r\n", strings.Join(notification.Recipients, ", ")))
|
||||
// Format From header with optional display name
|
||||
fromHeader := s.config.From
|
||||
if s.config.FromName != "" {
|
||||
fromHeader = fmt.Sprintf("%s <%s>", s.config.FromName, s.config.From)
|
||||
}
|
||||
|
||||
builder.WriteString(fmt.Sprintf("From: %s\r\n", fromHeader))
|
||||
|
||||
// Add To header (optional if only BCC is specified)
|
||||
if len(notification.Recipients) > 0 {
|
||||
builder.WriteString(fmt.Sprintf("To: %s\r\n", strings.Join(notification.Recipients, ", ")))
|
||||
}
|
||||
|
||||
// Add CC header (optional)
|
||||
if len(notification.CC) > 0 {
|
||||
builder.WriteString(fmt.Sprintf("Cc: %s\r\n", strings.Join(notification.CC, ", ")))
|
||||
}
|
||||
|
||||
// Note: BCC is intentionally NOT included in headers (that's the point of BCC!)
|
||||
|
||||
builder.WriteString(fmt.Sprintf("Subject: %s\r\n", notification.Subject))
|
||||
builder.WriteString("MIME-Version: 1.0\r\n")
|
||||
builder.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
|
||||
builder.WriteString("\r\n")
|
||||
builder.WriteString(notification.Body)
|
||||
|
||||
// Auto-detect HTML if content type not set
|
||||
contentType := notification.ContentType
|
||||
if contentType == "" {
|
||||
contentType = detectContentType(notification.Body)
|
||||
}
|
||||
|
||||
// Build message based on content type
|
||||
if contentType == domain.ContentTypeHTML {
|
||||
// Send multipart/alternative with both text and HTML
|
||||
s.buildMultipartMessage(&builder, notification)
|
||||
} else {
|
||||
// Send plain text only
|
||||
builder.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
|
||||
builder.WriteString("\r\n")
|
||||
builder.WriteString(notification.Body)
|
||||
}
|
||||
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
// buildMultipartMessage builds a multipart/alternative email with both text and HTML versions
|
||||
func (s *SMTPNotifier) buildMultipartMessage(builder *strings.Builder, notification *domain.Notification) {
|
||||
// Generate a unique boundary
|
||||
boundary := generateBoundary()
|
||||
|
||||
builder.WriteString(fmt.Sprintf("Content-Type: multipart/alternative; boundary=\"%s\"\r\n", boundary))
|
||||
builder.WriteString("\r\n")
|
||||
|
||||
// Plain text version (auto-generated from HTML)
|
||||
builder.WriteString(fmt.Sprintf("--%s\r\n", boundary))
|
||||
builder.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
|
||||
builder.WriteString("Content-Transfer-Encoding: 7bit\r\n")
|
||||
builder.WriteString("\r\n")
|
||||
builder.WriteString(htmlToPlainText(notification.Body))
|
||||
builder.WriteString("\r\n\r\n")
|
||||
|
||||
// HTML version
|
||||
builder.WriteString(fmt.Sprintf("--%s\r\n", boundary))
|
||||
builder.WriteString("Content-Type: text/html; charset=UTF-8\r\n")
|
||||
builder.WriteString("Content-Transfer-Encoding: 7bit\r\n")
|
||||
builder.WriteString("\r\n")
|
||||
builder.WriteString(notification.Body)
|
||||
builder.WriteString("\r\n\r\n")
|
||||
|
||||
// End boundary
|
||||
builder.WriteString(fmt.Sprintf("--%s--\r\n", boundary))
|
||||
}
|
||||
|
||||
// detectContentType auto-detects if the body is HTML
|
||||
func detectContentType(body string) domain.ContentType {
|
||||
trimmed := strings.TrimSpace(body)
|
||||
// Check for common HTML indicators
|
||||
if strings.HasPrefix(trimmed, "<") ||
|
||||
strings.Contains(trimmed, "<html") ||
|
||||
strings.Contains(trimmed, "<!DOCTYPE") ||
|
||||
strings.Contains(trimmed, "<p>") ||
|
||||
strings.Contains(trimmed, "<div>") ||
|
||||
strings.Contains(trimmed, "<br>") {
|
||||
return domain.ContentTypeHTML
|
||||
}
|
||||
return domain.ContentTypeText
|
||||
}
|
||||
|
||||
// generateBoundary generates a unique boundary string for multipart emails
|
||||
func generateBoundary() string {
|
||||
buf := make([]byte, 16)
|
||||
rand.Read(buf)
|
||||
return "boundary_" + hex.EncodeToString(buf)
|
||||
}
|
||||
|
||||
// htmlToPlainText converts HTML to plain text (simple implementation)
|
||||
func htmlToPlainText(htmlContent string) string {
|
||||
// Remove HTML tags
|
||||
re := regexp.MustCompile(`<[^>]*>`)
|
||||
text := re.ReplaceAllString(htmlContent, "")
|
||||
|
||||
// Decode HTML entities
|
||||
text = html.UnescapeString(text)
|
||||
|
||||
// Clean up whitespace
|
||||
text = strings.ReplaceAll(text, "\r\n", "\n")
|
||||
text = regexp.MustCompile(`\n{3,}`).ReplaceAllString(text, "\n\n")
|
||||
text = strings.TrimSpace(text)
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
// Validate checks if the notification is valid for SMTP
|
||||
func (s *SMTPNotifier) Validate(notification *domain.Notification) error {
|
||||
if err := s.BaseNotifier.Validate(notification); err != nil {
|
||||
return err
|
||||
if notification == nil {
|
||||
return fmt.Errorf("notification is nil")
|
||||
}
|
||||
|
||||
// For email, we need at least one recipient (To, CC, or BCC)
|
||||
totalRecipients := len(notification.Recipients) + len(notification.CC) + len(notification.BCC)
|
||||
if totalRecipients == 0 {
|
||||
return fmt.Errorf("email has no recipients (To, CC, or BCC required)")
|
||||
}
|
||||
|
||||
if notification.Type != s.Type() {
|
||||
return fmt.Errorf("notification type mismatch: expected %s, got %s", s.Type(), notification.Type)
|
||||
}
|
||||
|
||||
if notification.Subject == "" {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/igodwin/notifier/internal/domain"
|
||||
"github.com/igodwin/notifier/internal/logging"
|
||||
)
|
||||
|
||||
// AccountResolver is an interface for resolving default accounts
|
||||
@@ -24,10 +25,11 @@ type NotificationService struct {
|
||||
workerCount int
|
||||
stopChan chan struct{}
|
||||
wg sync.WaitGroup
|
||||
logger *logging.Logger
|
||||
}
|
||||
|
||||
// NewNotificationService creates a new notification service
|
||||
func NewNotificationService(factory domain.NotifierFactory, queue domain.Queue, workerCount int, accountResolver AccountResolver) *NotificationService {
|
||||
func NewNotificationService(factory domain.NotifierFactory, queue domain.Queue, workerCount int, accountResolver AccountResolver, logger *logging.Logger) *NotificationService {
|
||||
if workerCount <= 0 {
|
||||
workerCount = 10
|
||||
}
|
||||
@@ -39,6 +41,7 @@ func NewNotificationService(factory domain.NotifierFactory, queue domain.Queue,
|
||||
notifications: make(map[string]*domain.Notification),
|
||||
workerCount: workerCount,
|
||||
stopChan: make(chan struct{}),
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +100,9 @@ func (s *NotificationService) worker(ctx context.Context, id int) {
|
||||
func (s *NotificationService) processNotification(ctx context.Context, msg *domain.QueueMessage) {
|
||||
notification := msg.Notification
|
||||
|
||||
s.logger.Debugf("Processing notification - id=%s, type=%s, recipients=%d",
|
||||
notification.ID, notification.Type, len(notification.Recipients))
|
||||
|
||||
// Resolve account if not specified
|
||||
account := notification.Account
|
||||
if account == "" && s.accountResolver != nil {
|
||||
@@ -106,6 +112,8 @@ func (s *NotificationService) processNotification(ctx context.Context, msg *doma
|
||||
// Get the appropriate notifier
|
||||
notifier, err := s.factory.Create(notification.Type, account)
|
||||
if err != nil {
|
||||
s.logger.Errorf("Failed to create notifier - id=%s, type=%s, account=%s, error=%v",
|
||||
notification.ID, notification.Type, account, err)
|
||||
notification.Status = domain.StatusFailed
|
||||
notification.LastError = fmt.Sprintf("failed to create notifier: %v", err)
|
||||
s.queue.Nack(ctx, msg.ID, false)
|
||||
@@ -125,9 +133,13 @@ func (s *NotificationService) processNotification(ctx context.Context, msg *doma
|
||||
// Check if we should retry
|
||||
if notification.RetryCount < notification.MaxRetries {
|
||||
notification.Status = domain.StatusRetrying
|
||||
s.logger.Warnf("Notification send failed, will retry - id=%s, type=%s, account=%s, attempt=%d/%d, error=%s",
|
||||
notification.ID, notification.Type, account, notification.RetryCount, notification.MaxRetries, notification.LastError)
|
||||
s.queue.Nack(ctx, msg.ID, true) // Requeue
|
||||
} else {
|
||||
notification.Status = domain.StatusFailed
|
||||
s.logger.Errorf("Notification send failed permanently - id=%s, type=%s, account=%s, recipients=%v, attempts=%d, error=%s",
|
||||
notification.ID, notification.Type, account, notification.Recipients, notification.RetryCount, notification.LastError)
|
||||
s.queue.Nack(ctx, msg.ID, false) // Don't requeue
|
||||
}
|
||||
} else {
|
||||
@@ -135,6 +147,8 @@ func (s *NotificationService) processNotification(ctx context.Context, msg *doma
|
||||
now := time.Now()
|
||||
notification.SentAt = &now
|
||||
s.queue.Ack(ctx, msg.ID)
|
||||
s.logger.Infof("Notification sent successfully - id=%s, type=%s, account=%s, recipients=%v",
|
||||
notification.ID, notification.Type, account, notification.Recipients)
|
||||
}
|
||||
|
||||
s.updateNotification(notification)
|
||||
@@ -302,6 +316,30 @@ func (s *NotificationService) GetStats(ctx context.Context) (*domain.Notificatio
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// GetNotifiers returns information about available notifiers
|
||||
func (s *NotificationService) GetNotifiers(ctx context.Context) (*domain.NotifiersResponse, error) {
|
||||
supportedTypes := s.factory.SupportedTypes()
|
||||
notifiers := make([]domain.NotifierInfo, 0, len(supportedTypes))
|
||||
|
||||
for _, notifType := range supportedTypes {
|
||||
accounts := s.factory.GetAccounts(notifType)
|
||||
defaultAccount := ""
|
||||
if s.accountResolver != nil {
|
||||
defaultAccount = s.accountResolver.GetDefaultAccount(notifType)
|
||||
}
|
||||
|
||||
notifiers = append(notifiers, domain.NotifierInfo{
|
||||
Type: notifType,
|
||||
Accounts: accounts,
|
||||
DefaultAccount: defaultAccount,
|
||||
})
|
||||
}
|
||||
|
||||
return &domain.NotifiersResponse{
|
||||
Notifiers: notifiers,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// storeNotification stores a notification in memory
|
||||
func (s *NotificationService) storeNotification(notification *domain.Notification) {
|
||||
s.mu.Lock()
|
||||
|
||||
Reference in New Issue
Block a user