diff --git a/backend/go.mod b/backend/go.mod index 6a7d15cb2..f3ae9bf9c 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -12,6 +12,8 @@ require ( github.com/docker/distribution v2.8.3+incompatible github.com/docker/docker v28.5.1+incompatible github.com/docker/go-connections v0.6.0 + github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 + github.com/emersion/go-smtp v0.24.0 github.com/fsnotify/fsnotify v1.9.0 github.com/gin-contrib/cors v1.7.6 github.com/gin-gonic/gin v1.11.0 diff --git a/backend/go.sum b/backend/go.sum index 5ec04f407..76c30b947 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -169,6 +169,10 @@ github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+m github.com/dvsekhvalnov/jose2go v0.0.0-20170216131308-f21a8cedbbae/go.mod h1:7BvyPhdbLxMXIYTFPLsyJRFMsKmOZnQmzh6Gb+uquuM= github.com/eiannone/keyboard v0.0.0-20220611211555-0d226195f203 h1:XBBHcIb256gUJtLmY22n99HaZTz+r2Z51xUPi01m3wg= github.com/eiannone/keyboard v0.0.0-20220611211555-0d226195f203/go.mod h1:E1jcSv8FaEny+OP/5k9UxZVw9YFWGj7eI4KR/iOBqCg= +github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk= +github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= +github.com/emersion/go-smtp v0.24.0 h1:g6AfoF140mvW0vLNPD/LuCBLEAdlxOjIXqbIkJIS6Wk= +github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4McvHxCU2gsQ= github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0= diff --git a/backend/internal/api/notification_handler.go b/backend/internal/api/notification_handler.go new file mode 100644 index 000000000..5518e46ac --- /dev/null +++ b/backend/internal/api/notification_handler.go @@ -0,0 +1,121 @@ +package api + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/ofkm/arcane-backend/internal/dto" + "github.com/ofkm/arcane-backend/internal/middleware" + "github.com/ofkm/arcane-backend/internal/services" +) + +type NotificationHandler struct { + notificationService *services.NotificationService +} + +func NewNotificationHandler(group *gin.RouterGroup, notificationService *services.NotificationService, authMiddleware *middleware.AuthMiddleware) { + handler := &NotificationHandler{ + notificationService: notificationService, + } + + notifications := group.Group("/environments/:id/notifications") + notifications.Use(authMiddleware.WithAdminRequired().Add()) + { + notifications.GET("/settings", handler.GetAllSettings) + notifications.GET("/settings/:provider", handler.GetSettings) + notifications.POST("/settings", handler.CreateOrUpdateSettings) + notifications.DELETE("/settings/:provider", handler.DeleteSettings) + notifications.POST("/test/:provider", handler.TestNotification) + } +} + +func (h *NotificationHandler) GetAllSettings(c *gin.Context) { + settings, err := h.notificationService.GetAllSettings(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Map to DTOs + responses := make([]dto.NotificationSettingsResponse, len(settings)) + for i, setting := range settings { + responses[i] = dto.NotificationSettingsResponse{ + ID: setting.ID, + Provider: setting.Provider, + Enabled: setting.Enabled, + Config: setting.Config, + } + } + + c.JSON(http.StatusOK, responses) +} + +func (h *NotificationHandler) GetSettings(c *gin.Context) { + provider := c.Param("provider") + + settings, err := h.notificationService.GetSettingsByProvider(c.Request.Context(), provider) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Settings not found"}) + return + } + + response := dto.NotificationSettingsResponse{ + ID: settings.ID, + Provider: settings.Provider, + Enabled: settings.Enabled, + Config: settings.Config, + } + + c.JSON(http.StatusOK, response) +} + +func (h *NotificationHandler) CreateOrUpdateSettings(c *gin.Context) { + var req dto.NotificationSettingsRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + settings, err := h.notificationService.CreateOrUpdateSettings( + c.Request.Context(), + req.Provider, + req.Enabled, + req.Config, + ) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + response := dto.NotificationSettingsResponse{ + ID: settings.ID, + Provider: settings.Provider, + Enabled: settings.Enabled, + Config: settings.Config, + } + + c.JSON(http.StatusOK, response) +} + +func (h *NotificationHandler) DeleteSettings(c *gin.Context) { + provider := c.Param("provider") + + if err := h.notificationService.DeleteSettings(c.Request.Context(), provider); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Settings deleted successfully"}) +} + +func (h *NotificationHandler) TestNotification(c *gin.Context) { + provider := c.Param("provider") + testType := c.DefaultQuery("type", "simple") // "simple" or "image-update" + + if err := h.notificationService.TestNotification(c.Request.Context(), provider, testType); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Test notification sent successfully"}) +} diff --git a/backend/internal/bootstrap/router_bootstrap.go b/backend/internal/bootstrap/router_bootstrap.go index f9c9ebcbd..829768539 100644 --- a/backend/internal/bootstrap/router_bootstrap.go +++ b/backend/internal/bootstrap/router_bootstrap.go @@ -105,6 +105,7 @@ func setupRouter(cfg *config.Config, appServices *Services) *gin.Engine { api.NewSystemHandler(apiGroup, appServices.Docker, appServices.System, authMiddleware, cfg) api.NewUpdaterHandler(apiGroup, appServices.Updater, authMiddleware) api.NewVolumeHandler(apiGroup, appServices.Docker, appServices.Volume, authMiddleware) + api.NewNotificationHandler(apiGroup, appServices.Notification, authMiddleware) api.NewSettingsHandler(apiGroup, appServices.Settings, appServices.SettingsSearch, authMiddleware) api.NewCustomizeHandler(apiGroup, appServices.CustomizeSearch, authMiddleware) diff --git a/backend/internal/bootstrap/services_bootstrap.go b/backend/internal/bootstrap/services_bootstrap.go index 8c2a54523..8cc68971b 100644 --- a/backend/internal/bootstrap/services_bootstrap.go +++ b/backend/internal/bootstrap/services_bootstrap.go @@ -33,6 +33,7 @@ type Services struct { Updater *services.UpdaterService Event *services.EventService Version *services.VersionService + Notification *services.NotificationService } func initializeServices(ctx context.Context, db *database.DB, cfg *config.Config, httpClient *http.Client) (svcs *Services, dockerSrvice *services.DockerClientService, err error) { @@ -50,7 +51,8 @@ func initializeServices(ctx context.Context, db *database.DB, cfg *config.Config svcs.Docker = dockerClient svcs.User = services.NewUserService(db) svcs.ContainerRegistry = services.NewContainerRegistryService(db) - svcs.ImageUpdate = services.NewImageUpdateService(db, svcs.Settings, svcs.ContainerRegistry, svcs.Docker, svcs.Event) + svcs.Notification = services.NewNotificationService(db, cfg) + svcs.ImageUpdate = services.NewImageUpdateService(db, svcs.Settings, svcs.ContainerRegistry, svcs.Docker, svcs.Event, svcs.Notification) svcs.Image = services.NewImageService(db, svcs.Docker, svcs.ContainerRegistry, svcs.ImageUpdate, svcs.Event) svcs.Project = services.NewProjectService(db, svcs.Settings, svcs.Event, svcs.Image) svcs.Environment = services.NewEnvironmentService(db, httpClient) diff --git a/backend/internal/dto/notification_dto.go b/backend/internal/dto/notification_dto.go new file mode 100644 index 000000000..60edc7969 --- /dev/null +++ b/backend/internal/dto/notification_dto.go @@ -0,0 +1,16 @@ +package dto + +import "github.com/ofkm/arcane-backend/internal/models" + +type NotificationSettingsRequest struct { + Provider string `json:"provider" binding:"required"` + Enabled bool `json:"enabled"` + Config models.JSON `json:"config" binding:"required"` +} + +type NotificationSettingsResponse struct { + ID uint `json:"id"` + Provider string `json:"provider"` + Enabled bool `json:"enabled"` + Config models.JSON `json:"config"` +} diff --git a/backend/internal/models/notification.go b/backend/internal/models/notification.go new file mode 100644 index 000000000..f3bac5b9a --- /dev/null +++ b/backend/internal/models/notification.go @@ -0,0 +1,65 @@ +package models + +import ( + "time" +) + +type NotificationProvider string + +const ( + NotificationProviderDiscord NotificationProvider = "discord" + NotificationProviderEmail NotificationProvider = "email" +) + +type EmailTLSMode string + +const ( + EmailTLSModeNone EmailTLSMode = "none" + EmailTLSModeStartTLS EmailTLSMode = "starttls" + EmailTLSModeSSL EmailTLSMode = "ssl" +) + +type NotificationSettings struct { + ID uint `json:"id" gorm:"primaryKey"` + Provider string `json:"provider" gorm:"not null;index"` + Enabled bool `json:"enabled" gorm:"default:false"` + Config JSON `json:"config" gorm:"type:jsonb"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +func (NotificationSettings) TableName() string { + return "notification_settings" +} + +type NotificationLog struct { + ID uint `json:"id" gorm:"primaryKey"` + Provider string `json:"provider" gorm:"not null;index"` + ImageRef string `json:"imageRef" gorm:"not null"` + Status string `json:"status" gorm:"not null"` + Error *string `json:"error,omitempty"` + Metadata JSON `json:"metadata" gorm:"type:jsonb"` + SentAt time.Time `json:"sentAt" gorm:"not null;index"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +func (NotificationLog) TableName() string { + return "notification_logs" +} + +type DiscordConfig struct { + WebhookURL string `json:"webhookUrl"` + Username string `json:"username,omitempty"` + AvatarURL string `json:"avatarUrl,omitempty"` +} + +type EmailConfig struct { + SMTPHost string `json:"smtpHost"` + SMTPPort int `json:"smtpPort"` + SMTPUsername string `json:"smtpUsername"` + SMTPPassword string `json:"smtpPassword"` + FromAddress string `json:"fromAddress"` + ToAddresses []string `json:"toAddresses"` + TLSMode EmailTLSMode `json:"tlsMode"` +} diff --git a/backend/internal/services/image_update_service.go b/backend/internal/services/image_update_service.go index fc3b93d32..291980b19 100644 --- a/backend/internal/services/image_update_service.go +++ b/backend/internal/services/image_update_service.go @@ -19,11 +19,12 @@ import ( ) type ImageUpdateService struct { - db *database.DB - settingsService *SettingsService - registryService *ContainerRegistryService - dockerService *DockerClientService - eventService *EventService + db *database.DB + settingsService *SettingsService + registryService *ContainerRegistryService + dockerService *DockerClientService + eventService *EventService + notificationService *NotificationService } type ImageParts struct { @@ -32,13 +33,14 @@ type ImageParts struct { Tag string } -func NewImageUpdateService(db *database.DB, settingsService *SettingsService, registryService *ContainerRegistryService, dockerService *DockerClientService, eventService *EventService) *ImageUpdateService { +func NewImageUpdateService(db *database.DB, settingsService *SettingsService, registryService *ContainerRegistryService, dockerService *DockerClientService, eventService *EventService, notificationService *NotificationService) *ImageUpdateService { return &ImageUpdateService{ - db: db, - settingsService: settingsService, - registryService: registryService, - dockerService: dockerService, - eventService: eventService, + db: db, + settingsService: settingsService, + registryService: registryService, + dockerService: dockerService, + eventService: eventService, + notificationService: notificationService, } } @@ -102,6 +104,16 @@ func (s *ImageUpdateService) CheckImageUpdate(ctx context.Context, imageRef stri slog.String("imageRef", imageRef), slog.String("error", saveErr.Error())) } + + // Send notification if update is available + if digestResult.HasUpdate && s.notificationService != nil { + if notifErr := s.notificationService.SendImageUpdateNotification(ctx, imageRef, digestResult); notifErr != nil { + slog.WarnContext(ctx, "Failed to send update notification", + slog.String("imageRef", imageRef), + slog.String("error", notifErr.Error())) + } + } + return digestResult, nil } diff --git a/backend/internal/services/notification_service.go b/backend/internal/services/notification_service.go new file mode 100644 index 000000000..248366ddf --- /dev/null +++ b/backend/internal/services/notification_service.go @@ -0,0 +1,520 @@ +package services + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "net/mail" + "net/url" + "strings" + "text/template" + "time" + + "github.com/ofkm/arcane-backend/internal/config" + "github.com/ofkm/arcane-backend/internal/database" + "github.com/ofkm/arcane-backend/internal/dto" + "github.com/ofkm/arcane-backend/internal/models" + "github.com/ofkm/arcane-backend/internal/utils" + "github.com/ofkm/arcane-backend/internal/utils/notifications" + "github.com/ofkm/arcane-backend/resources" +) + +type NotificationService struct { + db *database.DB + config *config.Config +} + +func NewNotificationService(db *database.DB, cfg *config.Config) *NotificationService { + return &NotificationService{ + db: db, + config: cfg, + } +} + +func (s *NotificationService) GetAllSettings(ctx context.Context) ([]models.NotificationSettings, error) { + var settings []models.NotificationSettings + if err := s.db.WithContext(ctx).Find(&settings).Error; err != nil { + return nil, fmt.Errorf("failed to get notification settings: %w", err) + } + return settings, nil +} + +func (s *NotificationService) GetSettingsByProvider(ctx context.Context, provider string) (*models.NotificationSettings, error) { + var setting models.NotificationSettings + if err := s.db.WithContext(ctx).Where("provider = ?", provider).First(&setting).Error; err != nil { + return nil, err + } + return &setting, nil +} + +func (s *NotificationService) CreateOrUpdateSettings(ctx context.Context, provider string, enabled bool, config models.JSON) (*models.NotificationSettings, error) { + var setting models.NotificationSettings + + err := s.db.WithContext(ctx).Where("provider = ?", provider).First(&setting).Error + if err != nil { + setting = models.NotificationSettings{ + Provider: provider, + Enabled: enabled, + Config: config, + } + if err := s.db.WithContext(ctx).Create(&setting).Error; err != nil { + return nil, fmt.Errorf("failed to create notification settings: %w", err) + } + } else { + setting.Enabled = enabled + setting.Config = config + if err := s.db.WithContext(ctx).Save(&setting).Error; err != nil { + return nil, fmt.Errorf("failed to update notification settings: %w", err) + } + } + + return &setting, nil +} + +func (s *NotificationService) DeleteSettings(ctx context.Context, provider string) error { + if err := s.db.WithContext(ctx).Where("provider = ?", provider).Delete(&models.NotificationSettings{}).Error; err != nil { + return fmt.Errorf("failed to delete notification settings: %w", err) + } + return nil +} + +func (s *NotificationService) SendImageUpdateNotification(ctx context.Context, imageRef string, updateInfo *dto.ImageUpdateResponse) error { + settings, err := s.GetAllSettings(ctx) + if err != nil { + return fmt.Errorf("failed to get notification settings: %w", err) + } + + var errors []string + for _, setting := range settings { + if !setting.Enabled { + continue + } + + var sendErr error + switch setting.Provider { + case string(models.NotificationProviderDiscord): + sendErr = s.sendDiscordNotification(ctx, imageRef, updateInfo, setting.Config) + case string(models.NotificationProviderEmail): + sendErr = s.sendEmailNotification(ctx, imageRef, updateInfo, setting.Config) + default: + slog.WarnContext(ctx, "Unknown notification provider", "provider", setting.Provider) + continue + } + + status := "success" + var errMsg *string + if sendErr != nil { + status = "failed" + msg := sendErr.Error() + errMsg = &msg + errors = append(errors, fmt.Sprintf("%s: %s", setting.Provider, msg)) + } + + s.logNotification(ctx, setting.Provider, imageRef, status, errMsg, models.JSON{ + "hasUpdate": updateInfo.HasUpdate, + "currentDigest": updateInfo.CurrentDigest, + "latestDigest": updateInfo.LatestDigest, + "updateType": updateInfo.UpdateType, + }) + } + + if len(errors) > 0 { + return fmt.Errorf("notification errors: %s", strings.Join(errors, "; ")) + } + + return nil +} + +func (s *NotificationService) sendDiscordNotification(ctx context.Context, imageRef string, updateInfo *dto.ImageUpdateResponse, config models.JSON) error { + var discordConfig models.DiscordConfig + configBytes, err := json.Marshal(config) + if err != nil { + return fmt.Errorf("failed to marshal Discord config: %w", err) + } + if err := json.Unmarshal(configBytes, &discordConfig); err != nil { + return fmt.Errorf("failed to unmarshal Discord config: %w", err) + } + + if discordConfig.WebhookURL == "" { + return fmt.Errorf("discord webhook URL not configured") + } + + // Decrypt webhook URL if encrypted + webhookURL := discordConfig.WebhookURL + if decrypted, err := utils.Decrypt(webhookURL); err == nil { + webhookURL = decrypted + } + + // Validate webhook URL to prevent SSRF + if err := validateWebhookURL(webhookURL); err != nil { + return fmt.Errorf("invalid webhook URL: %w", err) + } + + username := discordConfig.Username + if username == "" { + username = "Arcane" + } + + color := 3447003 // Blue + if updateInfo.HasUpdate { + color = 15844367 // Gold for updates + } + + fields := []map[string]interface{}{ + { + "name": "Image", + "value": imageRef, + "inline": false, + }, + { + "name": "Update Available", + "value": fmt.Sprintf("%t", updateInfo.HasUpdate), + "inline": true, + }, + { + "name": "Update Type", + "value": updateInfo.UpdateType, + "inline": true, + }, + } + + if updateInfo.CurrentDigest != "" { + fields = append(fields, map[string]interface{}{ + "name": "Current Digest", + "value": truncateDigest(updateInfo.CurrentDigest), + "inline": true, + }) + } + if updateInfo.LatestDigest != "" { + fields = append(fields, map[string]interface{}{ + "name": "Latest Digest", + "value": truncateDigest(updateInfo.LatestDigest), + "inline": true, + }) + } + + payload := map[string]interface{}{ + "username": username, + "embeds": []map[string]interface{}{ + { + "title": "πŸ”” Container Image Update Available", + "description": fmt.Sprintf("A new update has been detected for **%s**", imageRef), + "color": color, + "fields": fields, + "timestamp": time.Now().Format(time.RFC3339), + }, + }, + } + + if discordConfig.AvatarURL != "" { + payload["avatar_url"] = discordConfig.AvatarURL + } + + payloadBytes, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to marshal Discord payload: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, webhookURL, bytes.NewBuffer(payloadBytes)) + if err != nil { + return fmt.Errorf("failed to create Discord request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("failed to send Discord notification: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("discord webhook returned status %d", resp.StatusCode) + } + + return nil +} + +func (s *NotificationService) sendEmailNotification(ctx context.Context, imageRef string, updateInfo *dto.ImageUpdateResponse, config models.JSON) error { + var emailConfig models.EmailConfig + configBytes, err := json.Marshal(config) + if err != nil { + return fmt.Errorf("failed to marshal email config: %w", err) + } + if err := json.Unmarshal(configBytes, &emailConfig); err != nil { + return fmt.Errorf("failed to unmarshal email config: %w", err) + } + + if emailConfig.SMTPHost == "" || emailConfig.SMTPPort == 0 { + return fmt.Errorf("SMTP host or port not configured") + } + if len(emailConfig.ToAddresses) == 0 { + return fmt.Errorf("no recipient email addresses configured") + } + + if _, err := mail.ParseAddress(emailConfig.FromAddress); err != nil { + return fmt.Errorf("invalid from address: %w", err) + } + for _, addr := range emailConfig.ToAddresses { + if _, err := mail.ParseAddress(addr); err != nil { + return fmt.Errorf("invalid to address %s: %w", addr, err) + } + } + + if emailConfig.SMTPPassword != "" { + if decrypted, err := utils.Decrypt(emailConfig.SMTPPassword); err == nil { + emailConfig.SMTPPassword = decrypted + } + } + + htmlBody, textBody, err := s.renderEmailTemplate(imageRef, updateInfo) + if err != nil { + return fmt.Errorf("failed to render email template: %w", err) + } + + subject := fmt.Sprintf("Container Update Available: %s", notifications.SanitizeForEmail(imageRef)) + message := notifications.BuildMultipartMessage(emailConfig.FromAddress, emailConfig.ToAddresses, subject, htmlBody, textBody) + + client, err := notifications.ConnectSMTP(ctx, emailConfig) + if err != nil { + return fmt.Errorf("failed to connect to SMTP server: %w", err) + } + defer client.Close() + + if err := client.SendMessage(emailConfig.FromAddress, emailConfig.ToAddresses, message); err != nil { + return fmt.Errorf("failed to send email: %w", err) + } + + return nil +} + +func (s *NotificationService) renderEmailTemplate(imageRef string, updateInfo *dto.ImageUpdateResponse) (string, string, error) { + data := map[string]interface{}{ + "LogoURL": "https://raw.githubusercontent.com/ofkm/arcane/main/backend/resources/images/logo-full.svg", + "AppURL": s.config.AppUrl, + "ImageRef": imageRef, + "HasUpdate": updateInfo.HasUpdate, + "UpdateType": updateInfo.UpdateType, + "CurrentDigest": truncateDigest(updateInfo.CurrentDigest), + "LatestDigest": truncateDigest(updateInfo.LatestDigest), + "CheckTime": updateInfo.CheckTime.Format(time.RFC1123), + } + + htmlContent, err := resources.FS.ReadFile("email-templates/image-update_html.tmpl") + if err != nil { + return "", "", fmt.Errorf("failed to read HTML template: %w", err) + } + + htmlTmpl, err := template.New("html").Parse(string(htmlContent)) + if err != nil { + return "", "", fmt.Errorf("failed to parse HTML template: %w", err) + } + + var htmlBuf bytes.Buffer + if err := htmlTmpl.ExecuteTemplate(&htmlBuf, "root", data); err != nil { + return "", "", fmt.Errorf("failed to execute HTML template: %w", err) + } + + textContent, err := resources.FS.ReadFile("email-templates/image-update_text.tmpl") + if err != nil { + return "", "", fmt.Errorf("failed to read text template: %w", err) + } + + textTmpl, err := template.New("text").Parse(string(textContent)) + if err != nil { + return "", "", fmt.Errorf("failed to parse text template: %w", err) + } + + var textBuf bytes.Buffer + if err := textTmpl.ExecuteTemplate(&textBuf, "root", data); err != nil { + return "", "", fmt.Errorf("failed to execute text template: %w", err) + } + + return htmlBuf.String(), textBuf.String(), nil +} + +func (s *NotificationService) TestNotification(ctx context.Context, provider string, testType string) error { + setting, err := s.GetSettingsByProvider(ctx, provider) + if err != nil { + return fmt.Errorf("failed to get settings for provider %s: %w", provider, err) + } + + testUpdate := &dto.ImageUpdateResponse{ + HasUpdate: true, + UpdateType: "digest", + CurrentDigest: "sha256:abc123def456789012345678901234567890", + LatestDigest: "sha256:xyz789ghi012345678901234567890123456", + CheckTime: time.Now(), + ResponseTimeMs: 100, + } + + switch provider { + case string(models.NotificationProviderDiscord): + return s.sendDiscordNotification(ctx, "test/image:latest", testUpdate, setting.Config) + case string(models.NotificationProviderEmail): + if testType == "image-update" { + return s.sendEmailNotification(ctx, "nginx:latest", testUpdate, setting.Config) + } + return s.sendTestEmail(ctx, setting.Config) + default: + return fmt.Errorf("unknown provider: %s", provider) + } +} + +func (s *NotificationService) sendTestEmail(ctx context.Context, config models.JSON) error { + var emailConfig models.EmailConfig + configBytes, err := json.Marshal(config) + if err != nil { + return fmt.Errorf("failed to marshal email config: %w", err) + } + if err := json.Unmarshal(configBytes, &emailConfig); err != nil { + return fmt.Errorf("failed to unmarshal email config: %w", err) + } + + if emailConfig.SMTPHost == "" || emailConfig.SMTPPort == 0 { + return fmt.Errorf("SMTP host or port not configured") + } + if len(emailConfig.ToAddresses) == 0 { + return fmt.Errorf("no recipient email addresses configured") + } + + if _, err := mail.ParseAddress(emailConfig.FromAddress); err != nil { + return fmt.Errorf("invalid from address: %w", err) + } + for _, addr := range emailConfig.ToAddresses { + if _, err := mail.ParseAddress(addr); err != nil { + return fmt.Errorf("invalid to address %s: %w", addr, err) + } + } + + if emailConfig.SMTPPassword != "" { + if decrypted, err := utils.Decrypt(emailConfig.SMTPPassword); err == nil { + emailConfig.SMTPPassword = decrypted + } + } + + htmlBody, textBody, err := s.renderTestEmailTemplate() + if err != nil { + return fmt.Errorf("failed to render test email template: %w", err) + } + + subject := "Test Email from Arcane" + message := notifications.BuildMultipartMessage(emailConfig.FromAddress, emailConfig.ToAddresses, subject, htmlBody, textBody) + + client, err := notifications.ConnectSMTP(ctx, emailConfig) + if err != nil { + return fmt.Errorf("failed to connect to SMTP server: %w", err) + } + defer client.Close() + + if err := client.SendMessage(emailConfig.FromAddress, emailConfig.ToAddresses, message); err != nil { + return fmt.Errorf("failed to send email: %w", err) + } + + return nil +} + +func (s *NotificationService) renderTestEmailTemplate() (string, string, error) { + data := map[string]interface{}{ + "LogoURL": "https://raw.githubusercontent.com/ofkm/arcane/main/backend/resources/images/logo-full.svg", + "AppURL": s.config.AppUrl, + } + + htmlContent, err := resources.FS.ReadFile("email-templates/test_html.tmpl") + if err != nil { + return "", "", fmt.Errorf("failed to read HTML template: %w", err) + } + + htmlTmpl, err := template.New("html").Parse(string(htmlContent)) + if err != nil { + return "", "", fmt.Errorf("failed to parse HTML template: %w", err) + } + + var htmlBuf bytes.Buffer + if err := htmlTmpl.ExecuteTemplate(&htmlBuf, "root", data); err != nil { + return "", "", fmt.Errorf("failed to execute HTML template: %w", err) + } + + textContent, err := resources.FS.ReadFile("email-templates/test_text.tmpl") + if err != nil { + return "", "", fmt.Errorf("failed to read text template: %w", err) + } + + textTmpl, err := template.New("text").Parse(string(textContent)) + if err != nil { + return "", "", fmt.Errorf("failed to parse text template: %w", err) + } + + var textBuf bytes.Buffer + if err := textTmpl.ExecuteTemplate(&textBuf, "root", data); err != nil { + return "", "", fmt.Errorf("failed to execute text template: %w", err) + } + + return htmlBuf.String(), textBuf.String(), nil +} + +func (s *NotificationService) logNotification(ctx context.Context, provider, imageRef, status string, errMsg *string, metadata models.JSON) { + log := &models.NotificationLog{ + Provider: provider, + ImageRef: imageRef, + Status: status, + Error: errMsg, + Metadata: metadata, + SentAt: time.Now(), + } + + if err := s.db.WithContext(ctx).Create(log).Error; err != nil { + slog.WarnContext(ctx, "Failed to log notification", + slog.String("provider", provider), + slog.String("error", err.Error())) + } +} + +func truncateDigest(digest string) string { + if len(digest) > 19 { + return digest[:19] + "..." + } + return digest +} + +// validateWebhookURL validates that the webhook URL is a valid Discord webhook URL +// This prevents SSRF attacks by ensuring the URL points to Discord's API +func validateWebhookURL(webhookURL string) error { + parsedURL, err := url.Parse(webhookURL) + if err != nil { + return fmt.Errorf("failed to parse webhook URL: %w", err) + } + + // Ensure it's HTTPS + if parsedURL.Scheme != "https" { + return fmt.Errorf("webhook URL must use HTTPS") + } + + // Validate it's a Discord webhook URL + validHosts := []string{ + "discord.com", + "discordapp.com", + } + + isValid := false + for _, validHost := range validHosts { + if parsedURL.Host == validHost || strings.HasSuffix(parsedURL.Host, "."+validHost) { + isValid = true + break + } + } + + if !isValid { + return fmt.Errorf("webhook URL must be a Discord webhook URL") + } + + // Validate it's a webhook path + if !strings.HasPrefix(parsedURL.Path, "/api/webhooks/") { + return fmt.Errorf("invalid Discord webhook path") + } + + return nil +} diff --git a/backend/internal/utils/notifications/email_util.go b/backend/internal/utils/notifications/email_util.go new file mode 100644 index 000000000..83534dc77 --- /dev/null +++ b/backend/internal/utils/notifications/email_util.go @@ -0,0 +1,224 @@ +package notifications + +import ( + "bytes" + "context" + "crypto/tls" + "errors" + "fmt" + "io" + "strings" + "time" + + "github.com/emersion/go-sasl" + "github.com/emersion/go-smtp" + "github.com/ofkm/arcane-backend/internal/models" +) + +// EmailClient wraps the SMTP client functionality +type EmailClient struct { + client *smtp.Client +} + +// ConnectSMTP establishes a connection to the SMTP server based on the TLS mode +func ConnectSMTP(ctx context.Context, config models.EmailConfig) (*EmailClient, error) { + // Check if context is still valid + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + + addr := fmt.Sprintf("%s:%d", config.SMTPHost, config.SMTPPort) + + tlsConfig := &tls.Config{ + ServerName: config.SMTPHost, + MinVersion: tls.VersionTLS12, + InsecureSkipVerify: false, + } + + var client *smtp.Client + var err error + + // Default to StartTLS if not set + tlsMode := config.TLSMode + if tlsMode == "" { + tlsMode = models.EmailTLSModeStartTLS + } + + // Connect based on TLS mode + switch tlsMode { + case models.EmailTLSModeNone: + client, err = smtp.Dial(addr) + case models.EmailTLSModeSSL: + client, err = smtp.DialTLS(addr, tlsConfig) + case models.EmailTLSModeStartTLS: + client, err = smtp.DialStartTLS(addr, tlsConfig) + default: + return nil, fmt.Errorf("unknown TLS mode: %s", tlsMode) + } + + if err != nil { + return nil, fmt.Errorf("failed to connect to SMTP server: %w", err) + } + + client.CommandTimeout = 10 * time.Second + + // Send HELLO command + if err := sendHelloCommand(client); err != nil { + client.Close() + return nil, fmt.Errorf("failed to send HELLO command: %w", err) + } + + // Authenticate if credentials are provided + if config.SMTPUsername != "" || config.SMTPPassword != "" { + if err := authenticateSMTP(client, config.SMTPUsername, config.SMTPPassword); err != nil { + client.Close() + return nil, fmt.Errorf("failed to authenticate: %w", err) + } + } + + return &EmailClient{client: client}, nil +} + +// sendHelloCommand sends the HELO/EHLO command to the SMTP server +func sendHelloCommand(client *smtp.Client) error { + // Try to get hostname, use "localhost" as fallback + hostname := "localhost" + return client.Hello(hostname) +} + +// authenticateSMTP authenticates with the SMTP server using PLAIN or LOGIN mechanisms +func authenticateSMTP(client *smtp.Client, username, password string) error { + // Try PLAIN authentication first + auth := sasl.NewPlainClient("", username, password) + err := client.Auth(auth) + + if err != nil { + // If PLAIN fails with unknown mechanism, try LOGIN + var smtpErr *smtp.SMTPError + if errors.As(err, &smtpErr) && smtpErr.Code == smtp.ErrAuthUnknownMechanism.Code { + auth = sasl.NewLoginClient(username, password) + err = client.Auth(auth) + } + } + + return err +} + +// SendMessage sends an email message through the SMTP client +func (ec *EmailClient) SendMessage(fromAddress string, toAddresses []string, message string) error { + // Set the sender + if err := ec.client.Mail(fromAddress, nil); err != nil { + return fmt.Errorf("failed to set sender: %w", err) + } + + // Set recipients + for _, to := range toAddresses { + if err := ec.client.Rcpt(to, nil); err != nil { + return fmt.Errorf("failed to set recipient %s: %w", to, err) + } + } + + // Get a writer to write the email data + w, err := ec.client.Data() + if err != nil { + return fmt.Errorf("failed to start data: %w", err) + } + + // Write the email content + _, err = io.Copy(w, strings.NewReader(message)) + if err != nil { + return fmt.Errorf("failed to write email data: %w", err) + } + + // Close the writer + if err := w.Close(); err != nil { + return fmt.Errorf("failed to close data writer: %w", err) + } + + return nil +} + +// Close closes the SMTP connection +func (ec *EmailClient) Close() error { + if ec.client != nil { + return ec.client.Close() + } + return nil +} + +// SanitizeForEmail restricts to safe characters for email (alphanumerics, dash, dot, slash, colon, at, underscore). +// It removes any character not matching the safe set and explicitly strips CRLF characters to prevent header injection. +func SanitizeForEmail(s string) string { + // First, strip out all carriage returns and newlines to prevent email header/content injection + s = strings.ReplaceAll(s, "\r", "") + s = strings.ReplaceAll(s, "\n", "") + + // Allow only: letters, numbers, dot, slash, dash, colon, at, underscore + safe := make([]rune, 0, len(s)) + for _, c := range s { + if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || + c == '.' || c == '/' || c == '-' || c == ':' || + c == '@' || c == '_' { + safe = append(safe, c) + } + } + return string(safe) +} + +// BuildSimpleMessage constructs a simple email message with headers +func BuildSimpleMessage(fromAddress string, toAddresses []string, subject string, body string) string { + var buf bytes.Buffer + + // Add headers + buf.WriteString(fmt.Sprintf("From: %s\r\n", fromAddress)) + buf.WriteString(fmt.Sprintf("To: %s\r\n", strings.Join(toAddresses, ", "))) + buf.WriteString(fmt.Sprintf("Subject: %s\r\n", subject)) + buf.WriteString(fmt.Sprintf("Date: %s\r\n", time.Now().Format(time.RFC1123Z))) + buf.WriteString("MIME-Version: 1.0\r\n") + buf.WriteString("Content-Type: text/plain; charset=UTF-8\r\n") + buf.WriteString("\r\n") + + // Add body + buf.WriteString(body) + + return buf.String() +} + +// BuildMultipartMessage constructs a MIME multipart email message with both HTML and text parts +func BuildMultipartMessage(fromAddress string, toAddresses []string, subject string, htmlBody string, textBody string) string { + var buf bytes.Buffer + boundary := fmt.Sprintf("boundary_%d", time.Now().UnixNano()) + + // Add headers + buf.WriteString(fmt.Sprintf("From: %s\r\n", fromAddress)) + buf.WriteString(fmt.Sprintf("To: %s\r\n", strings.Join(toAddresses, ", "))) + buf.WriteString(fmt.Sprintf("Subject: %s\r\n", subject)) + buf.WriteString(fmt.Sprintf("Date: %s\r\n", time.Now().Format(time.RFC1123Z))) + buf.WriteString("MIME-Version: 1.0\r\n") + buf.WriteString(fmt.Sprintf("Content-Type: multipart/alternative; boundary=\"%s\"\r\n", boundary)) + buf.WriteString("\r\n") + + // Add text part + buf.WriteString(fmt.Sprintf("--%s\r\n", boundary)) + buf.WriteString("Content-Type: text/plain; charset=UTF-8\r\n") + buf.WriteString("Content-Transfer-Encoding: 7bit\r\n") + buf.WriteString("\r\n") + buf.WriteString(textBody) + buf.WriteString("\r\n") + + // Add HTML part + buf.WriteString(fmt.Sprintf("--%s\r\n", boundary)) + buf.WriteString("Content-Type: text/html; charset=UTF-8\r\n") + buf.WriteString("Content-Transfer-Encoding: 7bit\r\n") + buf.WriteString("\r\n") + buf.WriteString(htmlBody) + buf.WriteString("\r\n") + + // End boundary + buf.WriteString(fmt.Sprintf("--%s--\r\n", boundary)) + + return buf.String() +} diff --git a/backend/resources/email-templates/image-update_html.tmpl b/backend/resources/email-templates/image-update_html.tmpl new file mode 100644 index 000000000..6f05b62a5 --- /dev/null +++ b/backend/resources/email-templates/image-update_html.tmpl @@ -0,0 +1,9 @@ +{{define "root"}}
+Arcane

Container Image Update

+

A new update has been detected for your container image.

Image:

+

{{.ImageRef}}


Status:

βœ“ Update Available

+

Update Type:

{{.UpdateType}}


+

Current Digest:

{{.CurrentDigest}}


+

Latest Digest:

{{.LatestDigest}}


+

Checked At:

{{.CheckTime}}

This is an automated notification from Arcane. Please review and update your container when ready.

+

Open Arcane Dashboard β†’

{{end}} \ No newline at end of file diff --git a/backend/resources/email-templates/image-update_text.tmpl b/backend/resources/email-templates/image-update_text.tmpl new file mode 100644 index 000000000..ac4327267 --- /dev/null +++ b/backend/resources/email-templates/image-update_text.tmpl @@ -0,0 +1,47 @@ +{{define "root"}}CONTAINER IMAGE UPDATE + +A new update has been detected for your container image. + +Image: + +{{.ImageRef}} + +------------------------------------------------------------------------------ +-- + +Status: + +βœ“ Update Available + +------------------------------------------------------------------------------ +-- + +Update Type: + +{{.UpdateType}} + +------------------------------------------------------------------------------ +-- + +Current Digest: + +{{.CurrentDigest}} + +------------------------------------------------------------------------------ +-- + +Latest Digest: + +{{.LatestDigest}} + +------------------------------------------------------------------------------ +-- + +Checked At: + +{{.CheckTime}} + +This is an automated notification from Arcane. Please review and update your +container when ready. + +Open Arcane Dashboard β†’ {{.AppURL}}{{end}} \ No newline at end of file diff --git a/backend/resources/email-templates/test_html.tmpl b/backend/resources/email-templates/test_html.tmpl new file mode 100644 index 000000000..af11984a5 --- /dev/null +++ b/backend/resources/email-templates/test_html.tmpl @@ -0,0 +1,3 @@ +{{define "root"}}
+Arcane

Test Email

Your email setup is working correctly!

+

Open Arcane Dashboard β†’

{{end}} \ No newline at end of file diff --git a/backend/resources/email-templates/test_text.tmpl b/backend/resources/email-templates/test_text.tmpl new file mode 100644 index 000000000..3f4b89e90 --- /dev/null +++ b/backend/resources/email-templates/test_text.tmpl @@ -0,0 +1,5 @@ +{{define "root"}}TEST EMAIL + +Your email setup is working correctly! + +Open Arcane Dashboard β†’ {{.AppURL}}{{end}} \ No newline at end of file diff --git a/backend/resources/embed.go b/backend/resources/embed.go index ac17278cd..48d373839 100644 --- a/backend/resources/embed.go +++ b/backend/resources/embed.go @@ -4,5 +4,5 @@ import "embed" // Embedded file systems for the project -//go:embed migrations images +//go:embed migrations images email-templates var FS embed.FS diff --git a/backend/resources/migrations/postgres/019_add_notifications.down.sql b/backend/resources/migrations/postgres/019_add_notifications.down.sql new file mode 100644 index 000000000..8733d0b5b --- /dev/null +++ b/backend/resources/migrations/postgres/019_add_notifications.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS notification_logs; +DROP TABLE IF EXISTS notification_settings; diff --git a/backend/resources/migrations/postgres/019_add_notifications.up.sql b/backend/resources/migrations/postgres/019_add_notifications.up.sql new file mode 100644 index 000000000..1cc02664e --- /dev/null +++ b/backend/resources/migrations/postgres/019_add_notifications.up.sql @@ -0,0 +1,25 @@ +CREATE TABLE IF NOT EXISTS notification_settings ( + id SERIAL PRIMARY KEY, + provider VARCHAR(50) NOT NULL, + enabled BOOLEAN DEFAULT FALSE, + config JSONB NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_notification_settings_provider ON notification_settings(provider); + +CREATE TABLE IF NOT EXISTS notification_logs ( + id SERIAL PRIMARY KEY, + provider VARCHAR(50) NOT NULL, + image_ref VARCHAR(255) NOT NULL, + status VARCHAR(50) NOT NULL, + error TEXT, + metadata JSONB, + sent_at TIMESTAMP NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_notification_logs_provider ON notification_logs(provider); +CREATE INDEX idx_notification_logs_sent_at ON notification_logs(sent_at); diff --git a/backend/resources/migrations/sqlite/019_add_notifications.down.sql b/backend/resources/migrations/sqlite/019_add_notifications.down.sql new file mode 100644 index 000000000..8733d0b5b --- /dev/null +++ b/backend/resources/migrations/sqlite/019_add_notifications.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS notification_logs; +DROP TABLE IF EXISTS notification_settings; diff --git a/backend/resources/migrations/sqlite/019_add_notifications.up.sql b/backend/resources/migrations/sqlite/019_add_notifications.up.sql new file mode 100644 index 000000000..019e7093b --- /dev/null +++ b/backend/resources/migrations/sqlite/019_add_notifications.up.sql @@ -0,0 +1,25 @@ +CREATE TABLE IF NOT EXISTS notification_settings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider VARCHAR(50) NOT NULL, + enabled BOOLEAN DEFAULT 0, + config TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_notification_settings_provider ON notification_settings(provider); + +CREATE TABLE IF NOT EXISTS notification_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider VARCHAR(50) NOT NULL, + image_ref VARCHAR(255) NOT NULL, + status VARCHAR(50) NOT NULL, + error TEXT, + metadata TEXT, + sent_at DATETIME NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_notification_logs_provider ON notification_logs(provider); +CREATE INDEX idx_notification_logs_sent_at ON notification_logs(sent_at); diff --git a/docs/NOTIFICATIONS.md b/docs/NOTIFICATIONS.md new file mode 100644 index 000000000..52fcc765a --- /dev/null +++ b/docs/NOTIFICATIONS.md @@ -0,0 +1,162 @@ +# Notification System + +This document describes how to configure and use the notification system in Arcane to receive alerts when container image updates are detected. + +## Overview + +Arcane can send notifications when it detects that container images have updates available. The notification system supports multiple providers: + +- **Discord** - Send notifications to Discord channels via webhooks +- **Email** - Send notifications via SMTP email + +## Configuration + +### Discord Notifications + +1. Navigate to **Settings β†’ Notifications** in the Arcane UI +2. Enable **Discord Notifications** +3. Configure the following settings: + - **Webhook URL**: Create a webhook in your Discord server settings and paste the URL here + - **Bot Username** (optional): The display name for the notification bot (default: "Arcane") + - **Avatar URL** (optional): A URL to an image to use as the bot's avatar +4. Click **Save Discord Settings** +5. Click **Test Discord** to verify the configuration + +#### Creating a Discord Webhook + +1. Open your Discord server +2. Go to Server Settings β†’ Integrations β†’ Webhooks +3. Click "New Webhook" +4. Configure the webhook (name, channel, avatar) +5. Copy the Webhook URL +6. Paste it into Arcane's Discord settings + +### Email Notifications + +1. Navigate to **Settings β†’ Notifications** in the Arcane UI +2. Enable **Email Notifications** +3. Configure the following settings: + - **SMTP Host**: Your SMTP server hostname (e.g., `smtp.gmail.com`) + - **SMTP Port**: SMTP port (usually 587 for TLS or 465 for SSL) + - **SMTP Username**: Authentication username for the SMTP server + - **SMTP Password**: Authentication password for the SMTP server + - **From Address**: Email address to send notifications from + - **To Addresses**: Comma-separated list of email addresses to receive notifications + - **Use TLS**: Enable TLS/SSL encryption (recommended) +4. Click **Save Email Settings** +5. Click **Test Email** to verify the configuration + +#### Common SMTP Settings + +**Gmail** +- SMTP Host: `smtp.gmail.com` +- SMTP Port: `587` +- Use TLS: Yes +- **Important Security Note**: You must use an App Password, not your regular Gmail password. Regular passwords will not work and should never be used for security reasons. To create an App Password: + 1. Go to your Google Account settings + 2. Navigate to Security β†’ 2-Step Verification + 3. Scroll down to "App passwords" + 4. Generate a new app password for "Mail" + 5. Use this generated password in Arcane + +**Outlook/Office 365** +- SMTP Host: `smtp.office365.com` +- SMTP Port: `587` +- Use TLS: Yes + +**Custom SMTP Server** +- Consult your email provider's documentation for SMTP settings + +## How It Works + +1. **Image Polling**: Arcane periodically checks for image updates based on your polling settings (Settings β†’ Docker) +2. **Update Detection**: When an update is detected, Arcane compares the remote image digest with the local image +3. **Notification Trigger**: If an update is available, Arcane sends notifications to all enabled providers +4. **Notification Content**: Notifications include: + - Image name and tag + - Whether an update is available + - Current and latest image digests + - Timestamp of the check + +## Security + +- Webhook URLs and SMTP passwords are encrypted before being stored in the database +- All sensitive configuration is handled server-side +- Test notifications help verify configuration without exposing credentials + +## Troubleshooting + +### Discord Notifications Not Working + +- Verify the webhook URL is correct and hasn't been deleted +- Check that the webhook has permission to post in the target channel +- Ensure Arcane can reach discord.com (check firewall/proxy settings) +- Review notification logs in the Arcane database + +### Email Notifications Not Working + +- Verify SMTP credentials are correct +- Check SMTP host and port settings +- Ensure TLS is enabled if required by your provider +- Check that Arcane can reach your SMTP server (firewall/network) +- Some providers (like Gmail) require app-specific passwords +- Review notification logs in the Arcane database + +### General Issues + +- Check the browser console for frontend errors +- Review Arcane server logs for backend errors +- Use the test notification buttons to isolate configuration issues +- Verify image polling is enabled (Settings β†’ Docker) + +## API Reference + +The notification system exposes the following API endpoints: + +### Get All Notification Settings +``` +GET /api/environments/0/notifications/settings +``` + +### Get Settings for a Specific Provider +``` +GET /api/environments/0/notifications/settings/{provider} +``` + +### Create or Update Settings +``` +POST /api/environments/0/notifications/settings +Content-Type: application/json + +{ + "provider": "discord", + "enabled": true, + "config": { + "webhookUrl": "https://discord.com/api/webhooks/...", + "username": "Arcane", + "avatarUrl": "" + } +} +``` + +### Delete Settings +``` +DELETE /api/environments/0/notifications/settings/{provider} +``` + +### Test Notification +``` +POST /api/environments/0/notifications/test/{provider} +``` + +## Future Enhancements + +Potential future notification providers: +- Slack +- Microsoft Teams +- Telegram +- Pushover +- Apprise (supports 90+ notification services) +- Custom webhooks + +If you'd like to see support for additional notification providers, please open an issue on GitHub. diff --git a/email-templates/build.ts b/email-templates/build.ts new file mode 100644 index 000000000..c8b86e76b --- /dev/null +++ b/email-templates/build.ts @@ -0,0 +1,115 @@ +import { render } from "@react-email/components"; +import * as fs from "node:fs"; +import * as path from "node:path"; + +const outputDir = "../backend/resources/email-templates"; + +if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); +} + +function getTemplateName(filename: string): string { + return filename.replace(".tsx", ""); +} + +/** + * Tag-aware wrapping: + * - Prefer breaking immediately after the last '>' within maxLen. + * - Never break at spaces. + * - If no '>' exists in the window, hard-break at maxLen. + */ +function tagAwareWrap(input: string, maxLen: number): string { + const out: string[] = []; + + for (const originalLine of input.split(/\r?\n/)) { + let line = originalLine; + while (line.length > maxLen) { + let breakPos = line.lastIndexOf(">", maxLen); + + // If '>' happens to be exactly at maxLen, break after it + if (breakPos === maxLen) breakPos = maxLen; + + // If we found a '>' before the limit, break right after it + if (breakPos > -1 && breakPos < maxLen) { + out.push(line.slice(0, breakPos + 1)); + line = line.slice(breakPos + 1); + continue; + } + + // No suitable tag end foundβ€”hard break + out.push(line.slice(0, maxLen)); + line = line.slice(maxLen); + } + out.push(line); + } + + return out.join("\n"); +} + +async function buildTemplateFile( + Component: any, + templateName: string, + isPlainText: boolean +) { + const rendered = await render(Component(Component.TemplateProps), { + plainText: isPlainText, + }); + + // Normalize quotes + const normalized = rendered.replace(/"/g, '"'); + + // Enforce line length: prefer tag boundaries, never spaces + const maxLen = isPlainText ? 78 : 998; // RFC-safe + const safe = tagAwareWrap(normalized, maxLen); + + const goTemplate = `{{define "root"}}${safe}{{end}}`; + const suffix = isPlainText ? "_text.tmpl" : "_html.tmpl"; + const templatePath = path.join(outputDir, `${templateName}${suffix}`); + + fs.writeFileSync(templatePath, goTemplate); +} + +async function discoverAndBuildTemplates() { + console.log("Discovering and building email templates..."); + + const emailsDir = "./emails"; + const files = fs.readdirSync(emailsDir); + + for (const file of files) { + if (!file.endsWith(".tsx")) continue; + + const templateName = getTemplateName(file); + const modulePath = `./${emailsDir}/${file}`; + + console.log(`Building ${templateName}...`); + + try { + const module = await import(modulePath); + const Component = module.default || module[Object.keys(module)[0]]; + + if (!Component) { + console.error(`βœ— No component found in ${file}`); + continue; + } + + if (!Component.TemplateProps) { + console.error(`βœ— No TemplateProps found in ${file}`); + continue; + } + + await buildTemplateFile(Component, templateName, false); // HTML + await buildTemplateFile(Component, templateName, true); // Text + + console.log(`βœ“ Built ${templateName}`); + } catch (error) { + console.error(`βœ— Error building ${templateName}:`, error); + } + } +} + +async function main() { + await discoverAndBuildTemplates(); + console.log("All templates built successfully!"); +} + +main().catch(console.error); \ No newline at end of file diff --git a/email-templates/components/base-template.tsx b/email-templates/components/base-template.tsx new file mode 100644 index 000000000..415cb8ab6 --- /dev/null +++ b/email-templates/components/base-template.tsx @@ -0,0 +1,77 @@ +import { Body, Container, Head, Html, Img, Link, Section, Text } from '@react-email/components'; + +interface BaseTemplateProps { + logoURL?: string; + appURL?: string; + children: React.ReactNode; +} + +export const BaseTemplate = ({ logoURL, appURL, children }: BaseTemplateProps) => { + return ( + + + + +
+ Arcane +
+
{children}
+ {appURL && ( +
+ + + Open Arcane Dashboard β†’ + + +
+ )} +
+ + + ); +}; + +const mainStyle = { + padding: '40px 20px', + backgroundColor: '#0f172a', + fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif", +}; + +const logoSection = { + textAlign: 'center' as const, + marginBottom: '32px', +}; + +const logoStyle = { + width: '180px', + height: 'auto', + display: 'inline-block', +}; + +const glassCard = { + backgroundColor: 'rgba(30, 41, 59, 0.6)', + backdropFilter: 'blur(20px)', + WebkitBackdropFilter: 'blur(20px)', + border: '1px solid rgba(148, 163, 184, 0.1)', + padding: '32px', + borderRadius: '16px', + boxShadow: '0 8px 32px 0 rgba(0, 0, 0, 0.37)', +}; + +const footerSection = { + textAlign: 'center' as const, + marginTop: '32px', + paddingTop: '24px', +}; + +const footerText = { + margin: '0', + fontSize: '14px', + lineHeight: '20px', +}; + +const footerLink = { + color: '#a78bfa', + textDecoration: 'none', + fontWeight: '500' as const, +}; diff --git a/email-templates/components/button.tsx b/email-templates/components/button.tsx new file mode 100644 index 000000000..ea1b3eed8 --- /dev/null +++ b/email-templates/components/button.tsx @@ -0,0 +1,33 @@ +import { Button as EmailButton } from "@react-email/components"; + +interface ButtonProps { + href: string; + children: React.ReactNode; + style?: React.CSSProperties; +} + +export const Button = ({ href, children, style = {} }: ButtonProps) => { + const buttonStyle = { + backgroundColor: "#000000", + color: "#ffffff", + padding: "12px 24px", + borderRadius: "4px", + fontSize: "15px", + fontWeight: "500", + cursor: "pointer", + marginTop: "10px", + ...style, + }; + + return ( +
+ + {children} + +
+ ); +}; + +const buttonContainer = { + textAlign: "center" as const, +}; diff --git a/email-templates/components/card-header.tsx b/email-templates/components/card-header.tsx new file mode 100644 index 000000000..cb63c6401 --- /dev/null +++ b/email-templates/components/card-header.tsx @@ -0,0 +1,32 @@ +import { Column, Heading, Row, Text } from '@react-email/components'; + +export default function CardHeader({ title, warning }: { title: string; warning?: boolean }) { + return ( + + + + {title} + + + {warning && Warning} + + ); +} + +const titleStyle = { + fontSize: '24px', + fontWeight: 'bold' as const, + margin: 0, + color: '#f1f5f9', +}; + +const warningStyle = { + backgroundColor: '#fbbf24', + color: '#78350f', + padding: '4px 12px', + borderRadius: '12px', + fontSize: '12px', + fontWeight: '600' as const, + display: 'inline-block', + margin: 0, +}; diff --git a/email-templates/emails/image-update.tsx b/email-templates/emails/image-update.tsx new file mode 100644 index 000000000..1f48faf6e --- /dev/null +++ b/email-templates/emails/image-update.tsx @@ -0,0 +1,228 @@ +import { Column, Hr, Row, Section, Text } from '@react-email/components'; +import { BaseTemplate } from '../components/base-template'; +import CardHeader from '../components/card-header'; +import { sharedPreviewProps, sharedTemplateProps } from '../props'; + +interface ImageUpdateEmailProps { + logoURL: string; + appURL: string; + imageRef: string; + hasUpdate: boolean; + updateType: string; + currentDigest: string; + latestDigest: string; + checkTime: string; +} + +export const ImageUpdateEmail = ({ + logoURL, + appURL, + imageRef, + hasUpdate, + updateType, + currentDigest, + latestDigest, + checkTime, +}: ImageUpdateEmailProps) => { + const truncateDigest = (digest: string) => { + if (digest.length > 19) { + return digest.substring(0, 19) + '...'; + } + return digest; + }; + + return ( + + + +
+ + {hasUpdate + ? `A new update has been detected for your container image.` + : `Your container image has been checked for updates.`} + +
+ +
+ + + Image: + + + {imageRef} + + + +
+ + + + Status: + + + + {hasUpdate ? 'βœ“ Update Available' : 'Up to Date'} + + + + + {updateType && ( + <> +
+ + + Update Type: + + + {updateType} + + + + )} + + {currentDigest && ( + <> +
+ + + Current Digest: + + + {truncateDigest(currentDigest)} + + + + )} + + {latestDigest && ( + <> +
+ + + Latest Digest: + + + {truncateDigest(latestDigest)} + + + + )} + + {checkTime && ( + <> +
+ + + Checked At: + + + {checkTime} + + + + )} +
+ +
+ + This is an automated notification from Arcane. + {hasUpdate && ' Please review and update your container when ready.'} + +
+
+ ); +}; + +export default ImageUpdateEmail; + +const mainTextStyle = { + fontSize: '16px', + lineHeight: '24px', + color: '#cbd5e1', + margin: '0 0 16px 0', +}; + +const infoSectionStyle = { + marginTop: '20px', + backgroundColor: 'rgba(15, 23, 42, 0.5)', + border: '1px solid rgba(148, 163, 184, 0.1)', + padding: '20px', + borderRadius: '12px', +}; + +const infoRowStyle = { + marginBottom: '0', +}; + +const labelColumnStyle = { + width: '140px', + verticalAlign: 'top' as const, + paddingRight: '12px', +}; + +const labelStyle = { + fontSize: '14px', + fontWeight: '600' as const, + color: '#94a3b8', + margin: '8px 0', +}; + +const valueStyle = { + fontSize: '14px', + color: '#e2e8f0', + margin: '8px 0', + wordBreak: 'break-word' as const, +}; + +const digestStyle = { + fontSize: '13px', + color: '#e2e8f0', + fontFamily: "'Courier New', Courier, monospace", + margin: '8px 0', +}; + +const statusUpdateStyle = { + fontSize: '14px', + fontWeight: '600' as const, + color: '#34d399', + margin: '8px 0', +}; + +const statusNoUpdateStyle = { + fontSize: '14px', + fontWeight: '600' as const, + color: '#94a3b8', + margin: '8px 0', +}; + +const dividerStyle = { + borderColor: 'rgba(148, 163, 184, 0.2)', + margin: '4px 0', +}; + +const footerStyle = { + fontSize: '13px', + lineHeight: '20px', + color: '#94a3b8', + margin: '0', +}; + +ImageUpdateEmail.TemplateProps = { + ...sharedTemplateProps, + imageRef: '{{.ImageRef}}', + hasUpdate: '{{.HasUpdate}}', + updateType: '{{.UpdateType}}', + currentDigest: '{{.CurrentDigest}}', + latestDigest: '{{.LatestDigest}}', + checkTime: '{{.CheckTime}}', +}; + +ImageUpdateEmail.PreviewProps = { + ...sharedPreviewProps, + imageRef: 'nginx:latest', + hasUpdate: true, + updateType: 'digest', + currentDigest: 'sha256:abc123def456789012345678901234567890', + latestDigest: 'sha256:xyz789ghi012345678901234567890123456', + checkTime: '2025-10-18 15:30:00 UTC', +}; diff --git a/email-templates/emails/test.tsx b/email-templates/emails/test.tsx new file mode 100644 index 000000000..f8fe510c0 --- /dev/null +++ b/email-templates/emails/test.tsx @@ -0,0 +1,34 @@ +import { Text } from '@react-email/components'; +import { BaseTemplate } from '../components/base-template'; +import CardHeader from '../components/card-header'; +import { sharedPreviewProps, sharedTemplateProps } from '../props'; + +interface TestEmailProps { + logoURL: string; + appURL: string; +} + +export const TestEmail = ({ logoURL, appURL }: TestEmailProps) => ( + + + Your email setup is working correctly! + +); + +export default TestEmail; + +const textStyle = { + fontSize: '16px', + lineHeight: '24px', + color: '#cbd5e1', + marginTop: '16px', + marginBottom: '0', +}; + +TestEmail.TemplateProps = { + ...sharedTemplateProps, +}; + +TestEmail.PreviewProps = { + ...sharedPreviewProps, +}; diff --git a/email-templates/package.json b/email-templates/package.json new file mode 100644 index 000000000..31dba3dc6 --- /dev/null +++ b/email-templates/package.json @@ -0,0 +1,25 @@ +{ + "name": "arcane-email-templates", + "version": "1.0.0", + "scripts": { + "preinstall": "npx only-allow pnpm", + "build": "tsx build.ts", + "build:watch": "tsx watch build.ts", + "dev": "email dev --port 3030", + "export": "email export" + }, + "dependencies": { + "@react-email/components": "0.1.1", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "tailwind-merge": "^3.3.1" + }, + "devDependencies": { + "@react-email/preview-server": "4.2.8", + "@types/node": "^24.0.10", + "@types/react": "^19.0.1", + "@types/react-dom": "^19.0.1", + "react-email": "4.2.8", + "tsx": "^4.0.0" + } +} diff --git a/email-templates/props.ts b/email-templates/props.ts new file mode 100644 index 000000000..c50e6349d --- /dev/null +++ b/email-templates/props.ts @@ -0,0 +1,9 @@ +export const sharedPreviewProps = { + logoURL: 'https://raw.githubusercontent.com/ofkm/arcane/main/backend/resources/images/logo-full.svg', + appURL: 'http://localhost:3552', +}; + +export const sharedTemplateProps = { + logoURL: '{{.LogoURL}}', + appURL: '{{.AppURL}}', +}; diff --git a/email-templates/tsconfig.json b/email-templates/tsconfig.json new file mode 100644 index 000000000..9ad2ca321 --- /dev/null +++ b/email-templates/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "allowSyntheticDefaultImports": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "types": ["node"] + }, + "include": [ + "**/*.ts", + "**/*.tsx" + ], + "exclude": [ + "node_modules", + "dist" + ] +} \ No newline at end of file diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 2bd077e37..671ce07a2 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -1361,6 +1361,55 @@ "events_delete_success": "Event \"{title}\" deleted successfully", "sidebar_dark_mode": "Dark", "sidebar_light_mode": "Light", + "notifications_title": "Notifications", + "notifications_description": "Configure notifications for container updates", + "notifications_read_only_title": "Read-only Mode", + "notifications_read_only_description": "Settings are read-only in this environment. Configuration changes are disabled.", + "notifications_discord_title": "Discord Notifications", + "notifications_discord_description": "Send notifications to Discord when container updates are detected", + "notifications_discord_enabled_label": "Enable Discord Notifications", + "notifications_discord_enabled_description": "Send update notifications to Discord via webhook", + "notifications_discord_webhook_url_label": "Webhook URL", + "notifications_discord_webhook_url_placeholder": "https://discord.com/api/webhooks/...", + "notifications_discord_webhook_url_help": "Discord webhook URL for sending notifications", + "notifications_discord_username_label": "Bot Username", + "notifications_discord_username_placeholder": "Arcane", + "notifications_discord_username_help": "Display name for the notification bot", + "notifications_discord_avatar_url_label": "Avatar URL (Optional)", + "notifications_discord_avatar_url_placeholder": "https://...", + "notifications_discord_avatar_url_help": "Avatar image URL for the notification bot", + "notifications_discord_save_button": "Save Discord Settings", + "notifications_discord_test_button": "Test Discord", + "notifications_email_title": "Email Notifications", + "notifications_email_description": "Send notifications via email when container updates are detected", + "notifications_email_enabled_label": "Enable Email Notifications", + "notifications_email_enabled_description": "Send update notifications via email (SMTP)", + "notifications_email_smtp_host_label": "SMTP Host", + "notifications_email_smtp_host_placeholder": "smtp.example.com", + "notifications_email_smtp_host_help": "SMTP server hostname", + "notifications_email_smtp_port_label": "SMTP Port", + "notifications_email_smtp_port_placeholder": "587", + "notifications_email_smtp_port_help": "SMTP server port (usually 587 or 465)", + "notifications_email_username_label": "SMTP Username", + "notifications_email_username_placeholder": "user@example.com", + "notifications_email_username_help": "SMTP authentication username", + "notifications_email_password_label": "SMTP Password", + "notifications_email_password_placeholder": "β€’β€’β€’β€’β€’β€’β€’β€’", + "notifications_email_password_help": "SMTP authentication password", + "notifications_email_from_address_label": "From Address", + "notifications_email_from_address_placeholder": "notifications@example.com", + "notifications_email_from_address_help": "Email address to send notifications from", + "notifications_email_to_addresses_label": "To Addresses", + "notifications_email_to_addresses_placeholder": "user1@example.com, user2@example.com", + "notifications_email_to_addresses_help": "Comma-separated list of email addresses to send notifications to", + "notifications_email_use_tls_label": "Use TLS", + "notifications_email_use_tls_description": "Enable TLS/SSL encryption for SMTP connection", + "notifications_email_save_button": "Save Email Settings", + "notifications_email_test_button": "Test Email", + "notifications_saved_success": "{provider} settings saved successfully", + "notifications_saved_failed": "Failed to save {provider} settings: {error}", + "notifications_test_success": "Test notification sent successfully via {provider}", + "notifications_test_failed": "Failed to send test notification: {error}", "glass_effect_title": "Glass Effect (Preview)", "glass_effect_description": "Enable modern glassmorphism design with blur, gradients, and ambient effects", "glass_effect_label": "Glassmorphism UI", diff --git a/frontend/src/lib/config/navigation-config.ts b/frontend/src/lib/config/navigation-config.ts index 3720bd559..a8b752769 100644 --- a/frontend/src/lib/config/navigation-config.ts +++ b/frontend/src/lib/config/navigation-config.ts @@ -16,6 +16,7 @@ import LockKeyholeIcon from '@lucide/svelte/icons/lock-keyhole'; import AlarmClockIcon from '@lucide/svelte/icons/alarm-clock'; import NavigationIcon from '@lucide/svelte/icons/navigation'; import FileTextIcon from '@lucide/svelte/icons/file-text'; +import BellIcon from '@lucide/svelte/icons/bell'; import { m } from '$lib/paraglide/messages'; export type NavigationItem = { @@ -68,7 +69,8 @@ export const navigationItems: Record = { { title: m.docker_title(), url: '/settings/docker', icon: DatabaseIcon }, { title: m.security_title(), url: '/settings/security', icon: ShieldIcon }, { title: m.navigation_title(), url: '/settings/navigation', icon: NavigationIcon }, - { title: m.users_title(), url: '/settings/users', icon: UserIcon } + { title: m.users_title(), url: '/settings/users', icon: UserIcon }, + { title: m.notifications_title(), url: '/settings/notifications', icon: BellIcon } ] } ] @@ -92,7 +94,6 @@ export function getAvailableMobileNavItems(): NavigationItem[] { const flatItems: NavigationItem[] = []; flatItems.push(...navigationItems.managementItems); - // Flatten customization children so individual pages can be pinned/selected for (const item of navigationItems.customizationItems) { if (item.items && item.items.length > 0) { flatItems.push(...item.items); diff --git a/frontend/src/lib/services/notification-service.ts b/frontend/src/lib/services/notification-service.ts new file mode 100644 index 000000000..0c5ffd575 --- /dev/null +++ b/frontend/src/lib/services/notification-service.ts @@ -0,0 +1,24 @@ +import BaseAPIService from './api-service'; +import type { NotificationSettings, TestNotificationResponse } from '$lib/types/notification.type'; +import { environmentStore } from '$lib/stores/environment.store.svelte'; + +export default class NotificationService extends BaseAPIService { + async getSettings(environmentId?: string): Promise { + const envId = environmentId || (await environmentStore.getCurrentEnvironmentId()); + const res = await this.api.get(`/environments/${envId}/notifications/settings`); + return res.data; + } + + async updateSettings(provider: string, settings: NotificationSettings): Promise { + const envId = await environmentStore.getCurrentEnvironmentId(); + const res = await this.api.post(`/environments/${envId}/notifications/settings`, settings); + return res.data; + } + + async testNotification(provider: string, type: string = 'simple'): Promise { + const envId = await environmentStore.getCurrentEnvironmentId(); + return this.handleResponse(this.api.post(`/environments/${envId}/notifications/test/${provider}?type=${type}`)); + } +} + +export const notificationService = new NotificationService(); diff --git a/frontend/src/lib/types/notification.type.ts b/frontend/src/lib/types/notification.type.ts new file mode 100644 index 000000000..a5e1515d7 --- /dev/null +++ b/frontend/src/lib/types/notification.type.ts @@ -0,0 +1,32 @@ +export type NotificationProvider = 'discord' | 'email'; +export type EmailTLSMode = 'none' | 'starttls' | 'ssl'; + +export interface DiscordConfig { + webhookUrl: string; + username: string; + avatarUrl: string; +} + +export interface EmailConfig { + smtpHost: string; + smtpPort: number; + smtpUsername: string; + smtpPassword: string; + fromAddress: string; + toAddresses: string[]; + tlsMode: EmailTLSMode; +} + +export type NotificationConfig = DiscordConfig | EmailConfig; + +export interface NotificationSettings { + provider: NotificationProvider; + enabled: boolean; + config?: Record; +} + +export interface TestNotificationResponse { + success: boolean; + message?: string; + error?: string; +} diff --git a/frontend/src/routes/settings/+page.svelte b/frontend/src/routes/settings/+page.svelte index aacfede89..e3a13335c 100644 --- a/frontend/src/routes/settings/+page.svelte +++ b/frontend/src/routes/settings/+page.svelte @@ -7,6 +7,7 @@ import UserIcon from '@lucide/svelte/icons/user'; import ShieldIcon from '@lucide/svelte/icons/shield'; import NavigationIcon from '@lucide/svelte/icons/navigation'; + import BellIcon from '@lucide/svelte/icons/bell'; import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'; import { Button } from '$lib/components/ui/button'; import { Card } from '$lib/components/ui/card'; diff --git a/frontend/src/routes/settings/notifications/+page.svelte b/frontend/src/routes/settings/notifications/+page.svelte new file mode 100644 index 000000000..e648a60ed --- /dev/null +++ b/frontend/src/routes/settings/notifications/+page.svelte @@ -0,0 +1,544 @@ + + + + {#snippet mainContent()} +
+
+ {#if isReadOnly} + + {m.notifications_read_only_title()} + {m.notifications_read_only_description()} + + {/if} + + + +
+ {m.notifications_discord_title()} + {m.notifications_discord_description()} +
+
+ + + + {#if $formInputs.discordEnabled.value} +
+ + {#if $formInputs.discordWebhookUrl.error} +

{$formInputs.discordWebhookUrl.error}

+ {/if} + + + + +
+ {/if} +
+ + {#if $formInputs.discordEnabled.value} + + {/if} + +
+ + + +
+ {m.notifications_email_title()} + {m.notifications_email_description()} +
+
+ + + + {#if $formInputs.emailEnabled.value} +
+
+ + {#if $formInputs.emailSmtpHost.error} +

{$formInputs.emailSmtpHost.error}

+ {/if} + +
+ + +

{m.notifications_email_smtp_port_help()}

+
+
+ +
+ + + +
+ + + {#if $formInputs.emailFromAddress.error} +

{$formInputs.emailFromAddress.error}

+ {/if} + +
+ +