package controllers import ( "fmt" "net/http" "net/url" "os" "strconv" "strings" "time" "fotbal-club/internal/config" "fotbal-club/internal/models" "fotbal-club/internal/services" "fotbal-club/pkg/email" "fotbal-club/pkg/logger" "fotbal-club/pkg/utils" "crypto/rand" "encoding/hex" "github.com/gin-gonic/gin" "gopkg.in/mail.v2" "gorm.io/datatypes" "gorm.io/gorm" ) type ContactController struct { DB *gorm.DB emailService email.EmailService } // GetContactMessages lists contact messages (admin) func (cc *ContactController) GetContactMessages(c *gin.Context) { if c.GetString("userRole") != "admin" { c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) return } // Pagination page, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("page", "1"))) limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "50"))) if page <= 0 { page = 1 } if limit <= 0 || limit > 200 { limit = 50 } // Filters search := strings.TrimSpace(c.DefaultQuery("search", "")) isReadParam := strings.TrimSpace(c.DefaultQuery("isRead", "")) var isRead *bool if isReadParam != "" { v := strings.ToLower(isReadParam) if v == "true" || v == "1" { t := true isRead = &t } else if v == "false" || v == "0" { f := false isRead = &f } } // Sorting (map UI fields to DB columns) sortBy := strings.TrimSpace(c.DefaultQuery("sortBy", "createdAt")) sortOrder := strings.ToLower(strings.TrimSpace(c.DefaultQuery("sortOrder", "desc"))) if sortOrder != "asc" { sortOrder = "desc" } sortField := "created_at" switch sortBy { case "name": sortField = "name" case "email": sortField = "email" case "subject": sortField = "subject" case "createdAt", "created_at": sortField = "created_at" } // Build query q := cc.DB.Model(&models.ContactMessage{}) if search != "" { s := "%" + strings.ToLower(search) + "%" q = q.Where("LOWER(name) LIKE ? OR LOWER(email) LIKE ? OR LOWER(subject) LIKE ? OR LOWER(message) LIKE ?", s, s, s, s) } if isRead != nil { q = q.Where("is_read = ?", *isRead) } // Count total var total int64 if err := q.Count(&total).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch messages"}) return } // Fetch page var items []models.ContactMessage offset := (page - 1) * limit if err := q.Order(sortField + " " + sortOrder).Offset(offset).Limit(limit).Find(&items).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch messages"}) return } // Compute pages (ceil) pages := 1 if limit > 0 { pages = (int(total) + limit - 1) / limit if pages == 0 { pages = 1 } } c.JSON(http.StatusOK, gin.H{ "data": items, "pagination": gin.H{ "total": total, "page": page, "limit": limit, "pages": pages, }, }) } // MarkMessageAsRead marks a message as read (admin) func (cc *ContactController) MarkMessageAsRead(c *gin.Context) { if c.GetString("userRole") != "admin" { c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) return } id, err := strconv.Atoi(c.Param("id")) if err != nil || id <= 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid id"}) return } if err := models.MarkMessageAsRead(cc.DB, uint(id)); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update"}) return } c.JSON(http.StatusOK, gin.H{"ok": true}) } func NewContactController(db *gorm.DB, emailService email.EmailService) *ContactController { return &ContactController{DB: db, emailService: emailService} } // recalcNewsletterAutomationEnabled enables automation when there is at least one // active subscriber; disables it when there are none. Persists to Settings and // updates the in-memory AppConfig flag used by the scheduler. func (cc *ContactController) recalcNewsletterAutomationEnabled() { var active int64 _ = cc.DB.Model(&models.NewsletterSubscription{}).Where("is_active = ?", true).Count(&active).Error enabled := active > 0 // Persist flag and, when enabling for the first time, ensure weekly digest is active with sane defaults var s models.Settings _ = cc.DB.First(&s).Error if s.ID == 0 { s = models.Settings{} } changed := false if s.NewsletterEnabled != enabled { s.NewsletterEnabled = enabled changed = true } if enabled { // Auto-activate weekly digest and preset schedule if not configured if !s.EnableWeekly { s.EnableWeekly = true changed = true } if strings.TrimSpace(s.NewsletterWeeklyDay) == "" { s.NewsletterWeeklyDay = "sun" changed = true } if s.NewsletterWeeklyHour < 0 || s.NewsletterWeeklyHour > 23 { s.NewsletterWeeklyHour = 9 changed = true } // Auto-activate match reminders with sane defaults if not configured if !s.EnableMatchReminders { s.EnableMatchReminders = true changed = true } if s.NewsletterReminderLeadHours <= 0 { s.NewsletterReminderLeadHours = 48 // 48h before kickoff changed = true } // Auto-activate match results notifications and default quiet hours if missing if !s.EnableResults { s.EnableResults = true changed = true } // Only set quiet hours if both are unset (0,0) to avoid overriding admin-configured values if s.NewsletterQuietStart == 0 && s.NewsletterQuietEnd == 0 { s.NewsletterQuietStart = 22 // 22:00 s.NewsletterQuietEnd = 8 // 08:00 changed = true } } if s.ID == 0 { _ = cc.DB.Create(&s).Error } else if changed { _ = cc.DB.Save(&s).Error } // Update runtime if config.AppConfig != nil { config.AppConfig.NewsletterEnabled = enabled } } // --- Newsletter admin: subscribers CRUD & preview/test --- // GetNewsletterSubscribers returns all newsletter subscribers (admin) func (cc *ContactController) GetNewsletterSubscribers(c *gin.Context) { if c.GetString("userRole") != "admin" { c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) return } var subs []models.NewsletterSubscription if err := cc.DB.Order("created_at DESC").Find(&subs).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load subscribers"}) return } c.JSON(http.StatusOK, subs) } // CreateNewsletterSubscriber creates a new newsletter subscriber (admin) // @Summary Create newsletter subscriber // @Description Creates a new newsletter subscriber with optional preferences (admin only) // @Tags admin // @Security Bearer // @Accept json // @Produce json // @Param subscriber body object true "Subscriber data" // @Success 201 {object} models.NewsletterSubscription // @Failure 400 {object} map[string]string // @Failure 403 {object} map[string]string // @Failure 500 {object} map[string]string // @Router /api/v1/admin/newsletter/subscribers [post] func (cc *ContactController) CreateNewsletterSubscriber(c *gin.Context) { if c.GetString("userRole") != "admin" { c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) return } var body struct { Email string `json:"email" binding:"required,email"` Preferences map[string]bool `json:"preferences"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload: " + err.Error()}) return } // Check if subscriber already exists var existingSub models.NewsletterSubscription if err := cc.DB.Where("email = ?", body.Email).First(&existingSub).Error; err == nil { // Subscriber exists, update status to active and preferences existingSub.IsActive = true if body.Preferences != nil { if body.Preferences != nil { m := datatypes.JSONMap{} for k, v := range body.Preferences { m[k] = v } existingSub.Preferences = m } } if err := cc.DB.Save(&existingSub).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update existing subscriber"}) return } cc.recalcNewsletterAutomationEnabled() c.JSON(http.StatusOK, existingSub) return } // Create new subscriber preferences := datatypes.JSONMap{} if body.Preferences != nil { for k, v := range body.Preferences { preferences[k] = v } } sub := models.NewsletterSubscription{ Email: body.Email, IsActive: true, Preferences: preferences, } if err := cc.DB.Create(&sub).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create subscriber"}) return } // Recalculate automation flag after adding subscriber cc.recalcNewsletterAutomationEnabled() c.JSON(http.StatusCreated, sub) } // UpdateNewsletterSubscriberStatus toggles is_active for a subscriber (admin) func (cc *ContactController) UpdateNewsletterSubscriberStatus(c *gin.Context) { if c.GetString("userRole") != "admin" { c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) return } id, err := strconv.Atoi(c.Param("id")) if err != nil || id <= 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) return } var body struct { IsActive bool `json:"is_active"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"}) return } var sub models.NewsletterSubscription if err := cc.DB.First(&sub, id).Error; err != nil { if err == gorm.ErrRecordNotFound { c.JSON(http.StatusNotFound, gin.H{"error": "subscriber not found"}) } else { c.JSON(http.StatusInternalServerError, gin.H{"error": "database error"}) } return } sub.IsActive = body.IsActive if err := cc.DB.Save(&sub).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save"}) return } // Recalculate automation flag after status change cc.recalcNewsletterAutomationEnabled() c.JSON(http.StatusOK, sub) } // UpdateNewsletterSubscriberPreferences updates preferences JSON (admin) func (cc *ContactController) UpdateNewsletterSubscriberPreferences(c *gin.Context) { if c.GetString("userRole") != "admin" { c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) return } id, err := strconv.Atoi(c.Param("id")) if err != nil || id <= 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) return } var prefsIn map[string]interface{} if err := c.ShouldBindJSON(&prefsIn); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid preferences"}) return } var sub models.NewsletterSubscription if err := cc.DB.First(&sub, id).Error; err != nil { if err == gorm.ErrRecordNotFound { c.JSON(http.StatusNotFound, gin.H{"error": "subscriber not found"}) } else { c.JSON(http.StatusInternalServerError, gin.H{"error": "database error"}) } return } m := datatypes.JSONMap{} for k, v := range prefsIn { m[k] = v } sub.Preferences = m if err := cc.DB.Save(&sub).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save preferences"}) return } c.JSON(http.StatusOK, sub) } // DeleteNewsletterSubscriber removes a subscriber (admin) func (cc *ContactController) DeleteNewsletterSubscriber(c *gin.Context) { if c.GetString("userRole") != "admin" { c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) return } id, err := strconv.Atoi(c.Param("id")) if err != nil || id <= 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) return } if err := cc.DB.Delete(&models.NewsletterSubscription{}, id).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete"}) return } // Recalculate automation flag after deletion cc.recalcNewsletterAutomationEnabled() c.JSON(http.StatusOK, gin.H{"ok": true}) } // PreviewNewsletter builds subject+HTML for given preferences (admin) func (cc *ContactController) PreviewNewsletter(c *gin.Context) { if c.GetString("userRole") != "admin" { c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) return } var input struct { Preferences map[string]interface{} `json:"preferences"` } _ = c.ShouldBindJSON(&input) // Determine requested types; support "weekly"=true shortcut types := []string{} freq := "daily" if input.Preferences != nil { if v, ok := input.Preferences["weekly"].(bool); ok && v { types = []string{"blogs", "events", "matches", "scores"} freq = "weekly" } } if len(types) == 0 { for _, k := range []string{"blogs", "events", "matches", "scores"} { if input.Preferences != nil { if v, ok := input.Preferences[k].(bool); ok && v { types = append(types, k) } } } } if len(types) == 0 { types = []string{"blogs", "matches"} } comps := []string{} if input.Preferences != nil { if raw, ok := input.Preferences["competitions"]; ok { if s, ok2 := raw.(string); ok2 && strings.TrimSpace(s) != "" { for _, p := range strings.Split(s, ",") { v := strings.TrimSpace(p) if v != "" { comps = append(comps, v) } } } } } prefs := services.NewsletterPrefs{Email: "preview@local", ContentTypes: types, Competitions: comps, Frequency: freq} subj, html := services.BuildNewsletterDigest("cache/prefetch", prefs) if strings.TrimSpace(html) == "" { html = "

Pro zadané preference nyní nemáme novinky.

" } if strings.TrimSpace(subj) == "" { subj = "Newsletter – náhled" } c.JSON(http.StatusOK, gin.H{"subject": subj, "html": html}) } // SendNewsletterTest sends a test email either to provided email(s) or to AdminEmail func (cc *ContactController) SendNewsletterTest(c *gin.Context) { if c.GetString("userRole") != "admin" { c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) return } var input struct { Email string `json:"email"` Emails []string `json:"emails"` Type string `json:"type"` } _ = c.ShouldBindJSON(&input) // Build sample newsletter content using digest builder for the selected type t := strings.ToLower(strings.TrimSpace(input.Type)) if t == "" { t = "newsletter" } // Recognize digest types; default to generic newsletter template with minimal body var subj, html string switch t { case "blogs", "events", "matches", "scores", "weekly": types := []string{} freq := "daily" if t == "weekly" { types = []string{"blogs", "events", "matches", "scores"} freq = "weekly" } else { types = []string{t} } prefs := services.NewsletterPrefs{Email: "test@local", ContentTypes: types, Competitions: []string{}, Frequency: freq} subj, html = services.BuildNewsletterDigest("cache/prefetch", prefs) if subj == "" { subj = "Test newsletter" } if html == "" { html = "

Testovací obsah není k dispozici.

" } default: subj = "Test newsletter" html = "

Toto je testovací e‑mail newsletteru.

" } // Prepare recipients recipients := []string{} for _, e := range input.Emails { if v := strings.TrimSpace(e); v != "" { recipients = append(recipients, v) } } if strings.TrimSpace(input.Email) != "" { recipients = append(recipients, strings.TrimSpace(input.Email)) } if len(recipients) == 0 { // fallback to admin email to := strings.TrimSpace(config.AppConfig.AdminEmail) if to == "" { to = strings.TrimSpace(config.AppConfig.SMTPFrom) } if to == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "No recipient specified"}) return } recipients = []string{to} } // Send one-by-one to exercise per-recipient template and tracking sent := []string{} for _, r := range recipients { data := &email.NewsletterData{Subject: subj, Content: html, Recipients: []string{r}} if err := cc.emailService.SendNewsletter(data); err != nil { logger.Error("Test send failed to %s: %v", r, err) continue } sent = append(sent, r) time.Sleep(50 * time.Millisecond) } if len(sent) == 0 { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to send test"}) return } // Match frontend expectations if len(sent) == 1 { c.JSON(http.StatusOK, gin.H{"message": "Test email sent", "recipient": sent[0], "type": t}) return } c.JSON(http.StatusOK, gin.H{"message": "Test email sent", "recipients": sent, "type": t}) } // --- Newsletter public endpoints --- // SubscribeToNewsletter creates or re-activates a subscription func (cc *ContactController) SubscribeToNewsletter(c *gin.Context) { var input struct { Email string `json:"email" binding:"required,email"` } if err := c.ShouldBindJSON(&input); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Valid email is required"}) return } emailStr := strings.ToLower(strings.TrimSpace(input.Email)) if err := models.SubscribeToNewsletter(cc.DB, emailStr); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to subscribe"}) return } // Build links (preferences/unsubscribe) token, _ := utils.GenerateSubscriberToken(emailStr, 60*24*30) baseFE := strings.TrimSuffix(config.AppConfig.FrontendBaseURL, "/") manageURL := baseFE + "/newsletter/preferences?token=" + url.QueryEscape(token) unsubscribeURL := baseFE + "/newsletter/unsubscribe/" + url.QueryEscape(emailStr) // Send styled newsletter welcome (best-effort) _ = cc.emailService.SendNewsletterWelcome(&email.NewsletterWelcomeData{Email: emailStr, UnsubscribeLink: unsubscribeURL}) // Auto-create user account for the subscriber (fan role) if not exists var existing models.User if err := cc.DB.Where("LOWER(email) = LOWER(?)", emailStr).First(&existing).Error; err == gorm.ErrRecordNotFound { // Generate a random initial password pwdBytes := make([]byte, 8) if _, err := rand.Read(pwdBytes); err != nil { // fallback to timestamp-derived hex if RNG fails pwdBytes = []byte(fmt.Sprintf("%d", time.Now().UnixNano())) } genPass := hex.EncodeToString(pwdBytes) if len(genPass) < 8 { genPass = genPass + "12345678" } hashed, herr := utils.HashPassword(genPass) if herr == nil { u := models.User{Email: strings.ToLower(emailStr), Password: hashed, Role: "fan", IsActive: true} if err := cc.DB.Create(&u).Error; err == nil { // Send account created email with login + manage links (best-effort) loginURL := baseFE + "/login" // Reset URL can point to forgot-password page (token flow is initiated by user) resetURL := baseFE + "/forgot-password" _ = cc.emailService.SendEmail(&email.EmailData{ Subject: "Váš fan účet byl vytvořen", To: []string{emailStr}, Template: "fan_account_created", Data: map[string]interface{}{ "Email": emailStr, "Password": genPass, "LoginURL": loginURL, "ResetURL": resetURL, "ManageURL": manageURL, "UnsubscribeURL": unsubscribeURL, }, }) } } } // Recalculate automation after (re)subscription cc.recalcNewsletterAutomationEnabled() c.JSON(http.StatusOK, gin.H{"message": "Subscribed"}) } // SetupNewsletterPreferences accepts token or email+preferences to initialize preferences func (cc *ContactController) SetupNewsletterPreferences(c *gin.Context) { var input struct { Email string `json:"email"` Token string `json:"token"` Preferences map[string]interface{} `json:"preferences"` } if err := c.ShouldBindJSON(&input); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"}) return } emailStr := strings.ToLower(strings.TrimSpace(input.Email)) if emailStr == "" && strings.TrimSpace(input.Token) != "" { if em, err := utils.ParseSubscriberToken(strings.TrimSpace(input.Token)); err == nil { emailStr = em } } if emailStr == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "Email or token required"}) return } var sub models.NewsletterSubscription if err := cc.DB.Where("email = ?", emailStr).First(&sub).Error; err != nil { // Create new sub = models.NewsletterSubscription{Email: emailStr, IsActive: true} } m := datatypes.JSONMap{} for k, v := range input.Preferences { m[k] = v } sub.Preferences = m sub.IsActive = true if sub.ID == 0 { _ = cc.DB.Create(&sub).Error } else { _ = cc.DB.Save(&sub).Error } c.JSON(http.StatusOK, gin.H{"message": "Preferences saved"}) } // GetNewsletterPreferencesByToken returns preferences for token holder func (cc *ContactController) GetNewsletterPreferencesByToken(c *gin.Context) { tok := strings.TrimSpace(c.Query("token")) if tok == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "token required"}) return } emailStr, err := utils.ParseSubscriberToken(tok) if err != nil || strings.TrimSpace(emailStr) == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid token"}) return } var sub models.NewsletterSubscription if err := cc.DB.Where("email = ?", emailStr).First(&sub).Error; err != nil { // Return empty defaults c.JSON(http.StatusOK, gin.H{"email": emailStr, "preferences": map[string]any{}}) return } c.JSON(http.StatusOK, gin.H{"email": sub.Email, "preferences": sub.Preferences, "is_active": sub.IsActive}) } // SaveNewsletterPreferencesByToken saves preferences provided with a token func (cc *ContactController) SaveNewsletterPreferencesByToken(c *gin.Context) { var input struct { Token string `json:"token"` Preferences map[string]interface{} `json:"preferences"` } if err := c.ShouldBindJSON(&input); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"}) return } emailStr, err := utils.ParseSubscriberToken(strings.TrimSpace(input.Token)) if err != nil || strings.TrimSpace(emailStr) == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid token"}) return } var sub models.NewsletterSubscription if err := cc.DB.Where("email = ?", emailStr).First(&sub).Error; err != nil { // Create subscription if missing sub = models.NewsletterSubscription{Email: emailStr, IsActive: true} } m := datatypes.JSONMap{} for k, v := range input.Preferences { m[k] = v } sub.Preferences = m sub.IsActive = true if sub.ID == 0 { _ = cc.DB.Create(&sub).Error } else { _ = cc.DB.Save(&sub).Error } c.JSON(http.StatusOK, gin.H{"message": "Preferences saved", "email": sub.Email, "preferences": sub.Preferences}) } // UnsubscribeByToken disables subscription using a token func (cc *ContactController) UnsubscribeByToken(c *gin.Context) { var input struct { Token string `json:"token"` } if err := c.ShouldBindJSON(&input); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"}) return } emailStr, err := utils.ParseSubscriberToken(strings.TrimSpace(input.Token)) if err != nil || strings.TrimSpace(emailStr) == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid token"}) return } if err := models.UnsubscribeFromNewsletter(cc.DB, emailStr); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to unsubscribe"}) return } // Recalculate automation after unsubscribe cc.recalcNewsletterAutomationEnabled() c.JSON(http.StatusOK, gin.H{"message": "Unsubscribed"}) } // SubmitContactForm handles public contact form submissions // POST /api/v1/contact func (cc *ContactController) SubmitContactForm(c *gin.Context) { var input struct { Name string `json:"name" binding:"required"` Email string `json:"email" binding:"required"` Subject string `json:"subject" binding:"required"` Message string `json:"message" binding:"required"` Source string `json:"source"` } if err := c.ShouldBindJSON(&input); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"}) return } name := strings.TrimSpace(input.Name) emailStr := strings.TrimSpace(input.Email) subject := strings.TrimSpace(input.Subject) message := strings.TrimSpace(input.Message) if name == "" || emailStr == "" || subject == "" || message == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "All fields are required"}) return } if !strings.Contains(emailStr, "@") { c.JSON(http.StatusBadRequest, gin.H{"error": "Valid email is required"}) return } if s, _ := services.FilterBadWords(subject); s != "" { subject = s } if m, _ := services.FilterBadWords(message); m != "" { message = m } ip := c.ClientIP() ua := c.GetHeader("User-Agent") src := strings.ToLower(strings.TrimSpace(input.Source)) switch src { case "sponsor": case "contact": default: src = "contact" } msg := models.ContactMessage{ Name: name, Email: emailStr, Subject: subject, Message: message, Source: src, IPAddress: ip, UserAgent: ua, } if err := cc.DB.Create(&msg).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save message"}) return } go func(m models.ContactMessage) { // 1) Notify primary contact(s) (club contact email / env fallbacks) _ = cc.emailService.SendContactForm(&email.ContactFormData{ Name: m.Name, Email: m.Email, Subject: m.Subject, Message: m.Message, IPAddress: m.IPAddress, UserAgent: m.UserAgent, }) // 2) Auto-forward to configured list when enabled var set models.Settings if err := cc.DB.First(&set).Error; err == nil && set.ContactForwardEnabled && strings.TrimSpace(set.ContactForwardList) != "" { // Build recipient list from ContactForwardList (comma/semicolon/space separated) parts := strings.FieldsFunc(set.ContactForwardList, func(r rune) bool { return r == ',' || r == ';' || r == ' ' || r == '\n' || r == '\t' }) uniq := make(map[string]struct{}) dest := make([]string, 0, len(parts)) // Exclude addresses that already received the primary notification (contact/admin emails) exclude := map[string]struct{}{} if v := strings.ToLower(strings.TrimSpace(set.ContactEmail)); v != "" { exclude[v] = struct{}{} } if config.AppConfig != nil { if v := strings.ToLower(strings.TrimSpace(config.AppConfig.ContactEmail)); v != "" { exclude[v] = struct{}{} } if v := strings.ToLower(strings.TrimSpace(config.AppConfig.AdminEmail)); v != "" { exclude[v] = struct{}{} } } for _, p := range parts { v := strings.TrimSpace(p) if v == "" { continue } lv := strings.ToLower(v) if _, ok := uniq[lv]; ok { continue } if _, skip := exclude[lv]; skip { continue } uniq[lv] = struct{}{} dest = append(dest, v) } if len(dest) > 0 { fwd := &email.EmailData{ Subject: fmt.Sprintf("Přeposláno: Kontaktní formulář - %s", strings.TrimSpace(m.Subject)), To: dest, Template: "contact_form", Data: struct { Name string Email string Subject string Message string Time string IP string Agent string }{ Name: m.Name, Email: m.Email, Subject: m.Subject, Message: m.Message, Time: m.CreatedAt.Format(time.RFC1123Z), IP: m.IPAddress, Agent: m.UserAgent, }, } if err := cc.emailService.SendEmail(fwd); err != nil { logger.Error("Auto-forward of contact message %d failed: %v", m.ID, err) } else { logger.Info("Auto-forwarded contact message %d to %v", m.ID, dest) } } } }(msg) c.JSON(http.StatusOK, gin.H{"message": "Message received", "id": msg.ID}) } func (cc *ContactController) AdminSmtpTest(c *gin.Context) { // ... rest of the code remains the same ... if c.GetString("userRole") != "admin" { c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) return } var input struct { Host string `json:"host"` Port int `json:"port"` Username string `json:"username"` Password string `json:"password"` From string `json:"from"` To string `json:"to"` Subject string `json:"subject"` Body string `json:"body"` UseTLS bool `json:"use_tls"` } if err := c.ShouldBindJSON(&input); err != nil { c.JSON(http.StatusBadRequest, gin.H{"ok": false, "error": "Invalid payload"}) return } if strings.TrimSpace(input.Host) == "" || input.Port <= 0 || strings.TrimSpace(input.From) == "" || strings.TrimSpace(input.To) == "" { c.JSON(http.StatusBadRequest, gin.H{"ok": false, "error": "host, port, from and to are required"}) return } d := mail.NewDialer(strings.TrimSpace(input.Host), input.Port, strings.TrimSpace(input.Username), input.Password) d.SSL = input.UseTLS d.Timeout = 30 * time.Second subj := strings.TrimSpace(input.Subject) if subj == "" { subj = "SMTP Test" } body := strings.TrimSpace(input.Body) if body == "" { body = "

Toto je testovací e‑mail SMTP z administrace.

" } m := mail.NewMessage() from := strings.TrimSpace(input.From) to := strings.TrimSpace(input.To) m.SetHeader("From", from) m.SetHeader("To", to) m.SetHeader("Subject", subj) m.SetDateHeader("Date", time.Now()) m.SetBody("text/plain", "SMTP test email") m.AddAlternative("text/html", body) if err := d.DialAndSend(m); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"ok": false, "error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"ok": true, "message": "Test email sent"}) } // GET /api/v1/newsletter/token/me (auth required) func (cc *ContactController) GetNewsletterTokenForUser(c *gin.Context) { u, ok := c.Get("user") if !ok || u == nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "Not authenticated"}) return } user := u.(*models.User) email := strings.TrimSpace(strings.ToLower(user.Email)) if email == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "User email not available"}) return } var sub models.NewsletterSubscription if err := cc.DB.Where("email = ?", email).First(&sub).Error; err != nil { _ = cc.DB.Create(&models.NewsletterSubscription{Email: email, IsActive: true}).Error } else if !sub.IsActive { _ = cc.DB.Model(&models.NewsletterSubscription{}).Where("email = ?", email).Update("is_active", true).Error } token, err := utils.GenerateSubscriberToken(email, 60*24) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"}) return } // Ensure automation is on when we just activated a subscription for user cc.recalcNewsletterAutomationEnabled() c.JSON(http.StatusOK, gin.H{"token": token}) } // POST /api/v1/admin/newsletter/send-digest func (cc *ContactController) SendNewsletterDigest(c *gin.Context) { if c.GetString("userRole") != "admin" { c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) return } var input struct { Type string `json:"type" binding:"required"` Competitions string `json:"competitions"` } if err := c.ShouldBindJSON(&input); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid input"}) return } t := strings.ToLower(strings.TrimSpace(input.Type)) allowed := map[string]bool{"blogs": true, "events": true, "matches": true, "scores": true, "weekly": true} if !allowed[t] { c.JSON(http.StatusBadRequest, gin.H{"error": "Unknown digest type"}) return } var subscribers []models.NewsletterSubscription if err := cc.DB.Where("is_active = ?", true).Find(&subscribers).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load subscribers"}) return } if len(subscribers) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "No active subscribers"}) return } prefs := services.NewsletterPrefs{Email: "digest@local", ContentTypes: []string{}, Competitions: []string{}, Frequency: "daily"} if t == "weekly" { prefs.ContentTypes = []string{"blogs", "events", "matches", "scores"} prefs.Frequency = "weekly" } else { prefs.ContentTypes = []string{t} } if strings.TrimSpace(input.Competitions) != "" { for _, p := range strings.Split(input.Competitions, ",") { if v := strings.TrimSpace(p); v != "" { prefs.Competitions = append(prefs.Competitions, v) } } } subj, html := services.BuildNewsletterDigest("cache/prefetch", prefs) if strings.TrimSpace(html) == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "No content for selected digest"}) return } recipients := make([]string, 0, len(subscribers)) for _, s := range subscribers { if s.Email != "" { recipients = append(recipients, s.Email) } } if len(recipients) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "No valid recipient emails"}) return } if subj == "" { subj = strings.Title(t) + " digest" } // Send per-recipient to avoid failing the whole request if some addresses bounce/misconfigure success := 0 failed := 0 failedList := make([]string, 0) for _, r := range recipients { d := &email.NewsletterData{Subject: subj, Content: html, Recipients: []string{r}} if err := cc.emailService.SendNewsletter(d); err != nil { failed++ failedList = append(failedList, r) } else { success++ } time.Sleep(50 * time.Millisecond) } if success == 0 { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to send digest newsletter to any recipient", "failed": failed}) return } c.JSON(http.StatusOK, gin.H{"message": "Digest newsletter sent", "recipients": success, "failed": failed, "type": t}) } // PATCH /api/v1/admin/newsletter/enable func (cc *ContactController) UpdateNewsletterAutomation(c *gin.Context) { if c.GetString("userRole") != "admin" { c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) return } var input struct { Enabled bool `json:"enabled"` } if err := c.ShouldBindJSON(&input); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"}) return } var s models.Settings _ = cc.DB.First(&s).Error if s.ID == 0 { s = models.Settings{} } s.NewsletterEnabled = input.Enabled // If enabling, ensure defaults for weekly/matches/results are set like auto-recalc does if input.Enabled { if !s.EnableWeekly { s.EnableWeekly = true } if strings.TrimSpace(s.NewsletterWeeklyDay) == "" { s.NewsletterWeeklyDay = "sun" } if s.NewsletterWeeklyHour < 0 || s.NewsletterWeeklyHour > 23 { s.NewsletterWeeklyHour = 9 } if !s.EnableMatchReminders { s.EnableMatchReminders = true } if s.NewsletterReminderLeadHours <= 0 { s.NewsletterReminderLeadHours = 48 } if !s.EnableResults { s.EnableResults = true } if s.NewsletterQuietStart == 0 && s.NewsletterQuietEnd == 0 { s.NewsletterQuietStart = 22 s.NewsletterQuietEnd = 8 } } if s.ID == 0 { if err := cc.DB.Create(&s).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to persist setting"}) return } } else if err := cc.DB.Save(&s).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to persist setting"}) return } if config.AppConfig != nil { config.AppConfig.NewsletterEnabled = input.Enabled } c.JSON(http.StatusOK, gin.H{"newsletter_enabled": input.Enabled}) } // GET /api/v1/admin/newsletter/status func (cc *ContactController) GetNewsletterStatus(c *gin.Context) { if c.GetString("userRole") != "admin" { c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) return } var total, active int64 cc.DB.Model(&models.NewsletterSubscription{}).Count(&total) cc.DB.Model(&models.NewsletterSubscription{}).Where("is_active = ?", true).Count(&active) var s models.Settings _ = cc.DB.First(&s).Error var subs []models.NewsletterSubscription _ = cc.DB.Where("is_active = ?", true).Limit(20).Find(&subs).Error sample := make([]string, 0, len(subs)) for _, s := range subs { if s.Email != "" { sample = append(sample, s.Email) } } interval := 24 * time.Hour if v := strings.TrimSpace(os.Getenv("NEWSLETTER_INTERVAL_HOURS")); v != "" { if d, err := time.ParseDuration(v + "h"); err == nil { interval = d } } next := time.Now().Add(interval) // Compute next scheduled weekly time (exact), using settings (default Sun 09:00) weeklyDay := strings.ToLower(strings.TrimSpace(s.NewsletterWeeklyDay)) if weeklyDay == "" { weeklyDay = "sun" } weeklyHour := s.NewsletterWeeklyHour if weeklyHour < 0 || weeklyHour > 23 { weeklyHour = 9 } // find next occurrence now := time.Now() target := time.Date(now.Year(), now.Month(), now.Day(), weeklyHour, 0, 0, 0, now.Location()) toWD := func(d string) time.Weekday { switch d { case "mon": return time.Monday case "tue": return time.Tuesday case "wed": return time.Wednesday case "thu": return time.Thursday case "fri": return time.Friday case "sat": return time.Saturday default: return time.Sunday } } for i := 0; i < 8; i++ { if target.Weekday() == toWD(weeklyDay) && target.After(now) { break } target = target.Add(24 * time.Hour) } c.JSON(http.StatusOK, gin.H{ "total_subscribers": total, "active_subscribers": active, "sample_recipients": sample, "interval_minutes": int(interval.Minutes()), "next_approximate": next, "newsletter_enabled": config.AppConfig != nil && config.AppConfig.NewsletterEnabled, // Scheduling detail "weekly_enabled": s.EnableWeekly, "weekly_day": weeklyDay, "weekly_hour": weeklyHour, "weekly_next_scheduled": target, "matches_enabled": s.EnableMatchReminders, "reminder_lead_hours": s.NewsletterReminderLeadHours, "results_enabled": s.EnableResults, "quiet_start": s.NewsletterQuietStart, "quiet_end": s.NewsletterQuietEnd, }) } // SendNewsletter sends a newsletter to all active subscribers (admin only) // @Summary Send newsletter // @Description Sends a newsletter to all active subscribers (admin only) // @Tags admin // @Security Bearer // @Accept json // @Produce json // @Param input body map[string]string true "Newsletter content (subject and body)" // @Success 200 {object} map[string]string // @Failure 400 {object} map[string]string // @Failure 401 {object} map[string]string // @Failure 403 {object} map[string]string // @Router /api/v1/admin/newsletter/send [post] func (cc *ContactController) SendNewsletter(c *gin.Context) { if c.GetString("userRole") != "admin" { c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) return } var input struct { Subject string `json:"subject" binding:"required"` Body string `json:"body"` Content string `json:"content"` } if err := c.ShouldBindJSON(&input); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid input"}) return } // Support both 'body' (backend) and 'content' (frontend variant) bodyText := strings.TrimSpace(input.Body) if bodyText == "" { bodyText = strings.TrimSpace(input.Content) } if bodyText == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "Newsletter body/content is required"}) return } // Fetch active subscribers var subscribers []models.NewsletterSubscription if err := cc.DB.Where("is_active = ?", true).Find(&subscribers).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load subscribers"}) return } if len(subscribers) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "No active subscribers"}) return } // Build recipient list recipients := make([]string, 0, len(subscribers)) for _, s := range subscribers { if s.Email != "" { recipients = append(recipients, s.Email) } } if len(recipients) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "No valid recipient emails"}) return } logger.Info("[SendNewsletter] sending to %d active subscribers", len(recipients)) // Send via email service data := &email.NewsletterData{ Subject: input.Subject, Content: bodyText, Recipients: recipients, } if err := cc.emailService.SendNewsletter(data); err != nil { logger.Error("Failed to send newsletter: %v", err) if config.AppConfig != nil && config.AppConfig.AppEnv != "production" { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to send newsletter", "details": err.Error()}) } else { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to send newsletter"}) } return } c.JSON(http.StatusOK, gin.H{"message": "Newsletter sent successfully", "recipients": len(recipients)}) } // UnsubscribeFromNewsletter handles newsletter unsubscription // @Summary Unsubscribe from newsletter // @Description Handles newsletter unsubscription requests // @Tags newsletter // @Produce json // @Param email path string true "Subscriber email" // @Success 200 {object} map[string]string // @Failure 400 {object} map[string]string // @Router /api/v1/newsletter/unsubscribe/{email} [post] func (cc *ContactController) UnsubscribeFromNewsletter(c *gin.Context) { email := c.Param("email") if email == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "Email is required"}) return } // Set subscription as inactive instead of deleting result := cc.DB.Model(&models.NewsletterSubscription{}).Where("email = ?", email).Update("is_active", false) if result.Error != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to unsubscribe"}) return } if result.RowsAffected == 0 { c.JSON(http.StatusNotFound, gin.H{"error": "Subscription not found"}) return } // Recalculate automation after unsubscribe cc.recalcNewsletterAutomationEnabled() c.JSON(http.StatusOK, gin.H{"message": "Successfully unsubscribed from newsletter"}) } // GetContactMessage returns a single contact message by ID (admin only) // @Summary Get contact message // @Description Returns a single contact message by ID (admin only) // @Tags admin // @Security Bearer // @Produce json // @Param id path int true "Message ID" // @Success 200 {object} models.ContactMessage // @Failure 400 {object} map[string]string // @Failure 401 {object} map[string]string // @Failure 403 {object} map[string]string // @Failure 404 {object} map[string]string // @Router /api/v1/admin/contact-messages/{id} [get] func (cc *ContactController) GetContactMessage(c *gin.Context) { id, err := strconv.Atoi(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid message ID"}) return } var message models.ContactMessage if err := cc.DB.First(&message, id).Error; err != nil { if err == gorm.ErrRecordNotFound { c.JSON(http.StatusNotFound, gin.H{"error": "Message not found"}) } else { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch message"}) } return } c.JSON(http.StatusOK, message) } // DeleteContactMessage deletes a contact message (admin only) // @Summary Delete contact message // @Description Deletes a contact message by ID (admin only) // @Tags admin // @Security Bearer // @Produce json // @Param id path int true "Message ID" // @Success 200 {object} map[string]string // @Failure 400 {object} map[string]string // @Failure 401 {object} map[string]string // @Failure 403 {object} map[string]string // @Router /api/v1/admin/contact-messages/{id} [delete] func (cc *ContactController) DeleteContactMessage(c *gin.Context) { id, err := strconv.Atoi(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid message ID"}) return } result := cc.DB.Delete(&models.ContactMessage{}, id) if result.Error != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete message"}) return } if result.RowsAffected == 0 { c.JSON(http.StatusNotFound, gin.H{"error": "Message not found"}) return } c.JSON(http.StatusOK, gin.H{"message": "Message deleted successfully"}) } // DeleteContactMessages deletes multiple contact messages (admin only) // @Summary Delete multiple contact messages // @Description Deletes multiple contact messages by their IDs (admin only) // @Tags admin // @Security Bearer // @Accept json // @Produce json // @Param ids body []int true "Array of message IDs to delete" // @Success 200 {object} map[string]string // @Failure 400 {object} map[string]string // @Failure 401 {object} map[string]string // @Failure 403 {object} map[string]string // @Router /api/v1/admin/contact-messages [delete] func (cc *ContactController) DeleteContactMessages(c *gin.Context) { var ids []int if err := c.ShouldBindJSON(&ids); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) return } if len(ids) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "No message IDs provided"}) return } result := cc.DB.Delete(&models.ContactMessage{}, ids) if result.Error != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete messages"}) return } if result.RowsAffected == 0 { c.JSON(http.StatusNotFound, gin.H{"error": "No messages found with the provided IDs"}) return } c.JSON(http.StatusOK, gin.H{ "message": fmt.Sprintf("Successfully deleted %d message(s)", result.RowsAffected), }) } // ForwardContactMessage forwards a contact message to a specified email (admin only) // @Summary Forward contact message // @Description Forwards a contact message to a specified email address (admin only) // @Tags admin // @Security Bearer // @Accept json // @Produce json // @Param id path int true "Message ID" // @Param input body map[string]string true "{ to_email: string }" // @Success 200 {object} map[string]string // @Failure 400 {object} map[string]string // @Failure 401 {object} map[string]string // @Failure 403 {object} map[string]string // @Failure 404 {object} map[string]string // @Router /api/v1/admin/contact-messages/{id}/forward [post] func (cc *ContactController) ForwardContactMessage(c *gin.Context) { if c.GetString("userRole") != "admin" { c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) return } id, err := strconv.Atoi(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid message ID"}) return } var input struct { ToEmail string `json:"to_email" binding:"required,email"` } if err := c.ShouldBindJSON(&input); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Valid email address is required"}) return } // Fetch the message var message models.ContactMessage if err := cc.DB.First(&message, id).Error; err != nil { if err == gorm.ErrRecordNotFound { c.JSON(http.StatusNotFound, gin.H{"error": "Message not found"}) } else { c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"}) } return } // Prepare email data for forwarding (Czech subject) forwardData := &email.EmailData{ Subject: fmt.Sprintf("Přeposláno: Kontaktní formulář - %s", message.Subject), To: []string{input.ToEmail}, Template: "contact_form", Data: struct { Name string Email string Subject string Message string Time string IP string Agent string }{ Name: message.Name, Email: message.Email, Subject: message.Subject, Message: message.Message, Time: message.CreatedAt.Format(time.RFC1123Z), IP: message.IPAddress, Agent: message.UserAgent, }, } if err := cc.emailService.SendEmail(forwardData); err != nil { logger.Error("Failed to forward contact message %d to %s: %v", message.ID, input.ToEmail, err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to forward message"}) return } c.JSON(http.StatusOK, gin.H{"message": "Message forwarded"}) } // @Summary Forward all contact messages // @Description Forwards all contact messages to a specified email address (admin only) // @Tags admin // @Security Bearer // @Accept json // @Produce json // @Param input body map[string]string true "{ to_email: string, to_emails: []string, save_default: bool }" // @Success 200 {object} map[string]string // @Failure 400 {object} map[string]string // @Failure 401 {object} map[string]string // @Failure 403 {object} map[string]string // @Router /api/v1/admin/contact-messages/forward-all [post] func (cc *ContactController) ForwardAllContactMessages(c *gin.Context) { if c.GetString("userRole") != "admin" { c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) return } var input struct { ToEmail string `json:"to_email"` ToEmails []string `json:"to_emails"` SaveDefault bool `json:"save_default"` } if err := c.ShouldBindJSON(&input); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"}) return } // Build recipients list (supports comma/semicolon/space separated string or array) recipients := make([]string, 0) add := func(s string) { v := strings.TrimSpace(s) if v != "" { recipients = append(recipients, v) } } if len(input.ToEmails) > 0 { for _, e := range input.ToEmails { add(e) } } if input.ToEmail != "" { // split by common separators to allow multiple addresses in a single string parts := strings.FieldsFunc(input.ToEmail, func(r rune) bool { return r == ',' || r == ';' || r == ' ' || r == '\n' || r == '\t' }) if len(parts) > 1 { for _, p := range parts { add(p) } } else { add(input.ToEmail) } } // Deduplicate if len(recipients) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "No recipient email provided"}) return } uniq := make(map[string]struct{}) out := make([]string, 0, len(recipients)) for _, e := range recipients { v := strings.TrimSpace(strings.ToLower(e)) if v == "" { continue } if _, ok := uniq[v]; ok { continue } uniq[v] = struct{}{} out = append(out, e) } recipients = out // Optionally save as default auto-forward list in Settings if input.SaveDefault { var set models.Settings if err := cc.DB.First(&set).Error; err != nil { if err == gorm.ErrRecordNotFound { set = models.Settings{} set.ContactForwardEnabled = true set.ContactForwardList = strings.Join(recipients, ", ") _ = cc.DB.Create(&set).Error } } else { set.ContactForwardEnabled = true set.ContactForwardList = strings.Join(recipients, ", ") _ = cc.DB.Save(&set).Error } } // Fetch all messages var messages []models.ContactMessage if err := cc.DB.Order("created_at DESC").Find(&messages).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch messages"}) return } if len(messages) == 0 { // Even if there are no messages now, ensure auto-forward is configured if !input.SaveDefault { var set models.Settings if err := cc.DB.First(&set).Error; err != nil { if err == gorm.ErrRecordNotFound { set = models.Settings{} set.ContactForwardEnabled = true set.ContactForwardList = strings.Join(recipients, ", ") _ = cc.DB.Create(&set).Error } } else { set.ContactForwardEnabled = true set.ContactForwardList = strings.Join(recipients, ", ") _ = cc.DB.Save(&set).Error } } c.JSON(http.StatusOK, gin.H{ "message": "Automatické přeposílání je nastaveno. Zatím nejsou žádné zprávy k přeposlání.", "count": 0, }) return } // Forward all messages asynchronously go func(msgs []models.ContactMessage, dest []string) { successCount := 0 for _, message := range msgs { forwardData := &email.EmailData{ Subject: fmt.Sprintf("Přeposláno: Kontaktní formulář - %s", message.Subject), To: dest, Template: "contact_form", Data: struct { Name string Email string Subject string Message string Time string IP string Agent string }{ Name: message.Name, Email: message.Email, Subject: message.Subject, Message: message.Message, Time: message.CreatedAt.Format(time.RFC1123Z), IP: message.IPAddress, Agent: message.UserAgent, }, } if err := cc.emailService.SendEmail(forwardData); err != nil { logger.Error("Failed to forward contact message %d to %v: %v", message.ID, dest, err) } else { successCount++ } } logger.Info("Forwarded %d of %d contact messages to %v", successCount, len(msgs), dest) }(messages, recipients) c.JSON(http.StatusOK, gin.H{ "message": fmt.Sprintf("Přeposílám %d zpráv na: %s", len(messages), strings.Join(recipients, ", ")), "count": len(messages), }) }