package controllers import ( "encoding/base64" "net/http" "net/url" "strconv" "strings" "time" "fotbal-club/internal/config" "fotbal-club/internal/models" "fotbal-club/internal/services" "github.com/gin-gonic/gin" "gorm.io/gorm" ) // EmailController handles tracking endpoints and basic analytics type EmailController struct { DB *gorm.DB } // sendUmamiEmailEvent sends a custom event to Umami if configured. func sendUmamiEmailEvent(name, urlPath, title string, data map[string]any) { cfg := config.AppConfig if cfg == nil || cfg.UmamiURL == "" || cfg.UmamiWebsiteID == "" { return } svc := services.NewUmamiService() // Best-effort, ignore error _ = svc.SendEvent(cfg.UmamiWebsiteID, name, urlPath, title, data, "email") } // Admin: events for a specific email log // GET /api/v1/admin/newsletter/stats/:id/events func (ec *EmailController) GetEmailEventsForLog(c *gin.Context) { if c.GetString("userRole") != "admin" { c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) return } idStr := c.Param("id") id, err := strconv.Atoi(idStr) if err != nil || id <= 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) return } // Verify log exists var log models.EmailLog if err := ec.DB.First(&log, id).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "email log not found"}) return } var events []models.EmailEvent if err := ec.DB.Where("email_log_id = ?", log.ID).Order("created_at ASC").Find(&events).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load events"}) return } c.JSON(http.StatusOK, gin.H{"data": events}) } func NewEmailController(db *gorm.DB) *EmailController { return &EmailController{DB: db} } // 1x1 transparent GIF bytes var oneByOneGIF = []byte{ 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00, 0x01, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0x21, 0xf9, 0x04, 0x01, 0x00, 0x00, 0x00, 0x00, 0x2c, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x02, 0x02, 0x44, 0x01, 0x00, 0x3b, } // OpenPixel records an open event and returns a transparent GIF // GET /api/v1/email/open.gif?m=&t= func (ec *EmailController) OpenPixel(c *gin.Context) { idStr := c.Query("m") tok := strings.TrimSpace(c.Query("t")) if idStr == "" || tok == "" { c.Data(http.StatusOK, "image/gif", oneByOneGIF) return } id, _ := strconv.Atoi(idStr) var log models.EmailLog if err := ec.DB.First(&log, id).Error; err == nil && log.Token == tok { meta := models.EmailEvent{EmailLogID: log.ID, EventType: "open", Meta: map[string]any{ "ua": c.Request.UserAgent(), "ip": c.ClientIP(), }} _ = ec.DB.Create(&meta).Error // Send Umami event (non-blocking) go sendUmamiEmailEvent("Email Open", "/email/open", "Email Open", map[string]any{ "recipient": log.RecipientEmail, "subject": log.Subject, }) } c.Header("Cache-Control", "no-store, must-revalidate") c.Data(http.StatusOK, "image/gif", oneByOneGIF) } // ClickRedirect records a click event then redirects to the target URL // GET /api/v1/email/click?m=&t=&u= func (ec *EmailController) ClickRedirect(c *gin.Context) { idStr := c.Query("m") tok := strings.TrimSpace(c.Query("t")) target := c.Query("u") if target == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "missing u"}) return } // allow both raw absolute or base64-encoded URLs if strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") { // ok } else { if decoded, err := base64.URLEncoding.DecodeString(target); err == nil { target = string(decoded) } } // Validate URL if u, err := url.Parse(target); err != nil || (u.Scheme != "http" && u.Scheme != "https") { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid url"}) return } if idStr != "" && tok != "" { if id, err := strconv.Atoi(idStr); err == nil { var log models.EmailLog if err := ec.DB.First(&log, id).Error; err == nil && log.Token == tok { meta := models.EmailEvent{EmailLogID: log.ID, EventType: "click", Meta: map[string]any{ "url": target, "ua": c.Request.UserAgent(), "ip": c.ClientIP(), }} _ = ec.DB.Create(&meta).Error // Send Umami event (non-blocking) go sendUmamiEmailEvent("Email Click", "/email/click", "Email Click", map[string]any{ "recipient": log.RecipientEmail, "subject": log.Subject, "url": target, }) } } } c.Redirect(http.StatusFound, target) } // MarkSpam marks an email as spam and (optionally) deactivates the subscriber // GET /api/v1/email/spam?m=&t= func (ec *EmailController) MarkSpam(c *gin.Context) { idStr := c.Query("m") tok := strings.TrimSpace(c.Query("t")) id, _ := strconv.Atoi(idStr) var log models.EmailLog if err := ec.DB.First(&log, id).Error; err == nil && log.Token == tok { _ = ec.DB.Create(&models.EmailEvent{EmailLogID: log.ID, EventType: "spam", Meta: map[string]any{"at": time.Now().Format(time.RFC3339)}}).Error // try to deactivate subscription _ = ec.DB.Model(&models.NewsletterSubscription{}).Where("email = ?", log.RecipientEmail).Update("is_active", false).Error // Send Umami event (non-blocking) go sendUmamiEmailEvent("Email Spam", "/email/spam", "Email Marked Spam", map[string]any{ "recipient": log.RecipientEmail, "subject": log.Subject, }) } c.JSON(http.StatusOK, gin.H{"ok": true}) } // Unsubscribe marks unsubscribe and deactivates the subscriber // GET /api/v1/email/unsubscribe?m=&t= func (ec *EmailController) Unsubscribe(c *gin.Context) { idStr := c.Query("m") tok := strings.TrimSpace(c.Query("t")) id, _ := strconv.Atoi(idStr) var log models.EmailLog if err := ec.DB.First(&log, id).Error; err == nil && log.Token == tok { _ = ec.DB.Create(&models.EmailEvent{EmailLogID: log.ID, EventType: "unsubscribe", Meta: map[string]any{"at": time.Now().Format(time.RFC3339)}}).Error _ = ec.DB.Model(&models.NewsletterSubscription{}).Where("email = ?", log.RecipientEmail).Update("is_active", false).Error // Send Umami event (non-blocking) go sendUmamiEmailEvent("Email Unsubscribe", "/email/unsubscribe", "Email Unsubscribe", map[string]any{ "recipient": log.RecipientEmail, "subject": log.Subject, }) } c.JSON(http.StatusOK, gin.H{"ok": true}) } // Admin: recent email logs with event counts // GET /api/v1/admin/newsletter/stats/recent func (ec *EmailController) GetRecentEmailStats(c *gin.Context) { if c.GetString("userRole") != "admin" { c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) return } var logs []models.EmailLog _ = ec.DB.Order("created_at DESC").Limit(50).Find(&logs).Error // aggregate events per log type Row struct { ID uint Opens int64 Clicks int64 Spam int64 Unsubs int64 } rows := map[uint]*Row{} var evs []models.EmailEvent _ = ec.DB.Where("email_log_id IN ?", func() []uint { ids := make([]uint, 0, len(logs)) for _, l := range logs { ids = append(ids, l.ID) } if len(ids) == 0 { ids = []uint{0} } return ids }()).Find(&evs).Error for _, e := range evs { r := rows[e.EmailLogID] if r == nil { r = &Row{ID: e.EmailLogID}; rows[e.EmailLogID] = r } switch strings.ToLower(e.EventType) { case "open": r.Opens++ case "click": r.Clicks++ case "spam": r.Spam++ case "unsubscribe": r.Unsubs++ } } // build response out := make([]gin.H, 0, len(logs)) for _, l := range logs { r := rows[l.ID] var opens, clicks, spam, unsubs int64 if r != nil { opens, clicks, spam, unsubs = r.Opens, r.Clicks, r.Spam, r.Unsubs } out = append(out, gin.H{ "id": l.ID, "created_at": l.CreatedAt, "subject": l.Subject, "recipient": l.RecipientEmail, "type": l.Type, "status": l.Status, "opens": opens, "clicks": clicks, "spam": spam, "unsubs": unsubs, }) } c.JSON(http.StatusOK, gin.H{"data": out}) }