package email import ( "bytes" "crypto/rand" "crypto/tls" "encoding/hex" "fmt" "html/template" "net/url" "os" "path/filepath" "reflect" "regexp" "strconv" "strings" "time" "fotbal-club/internal/config" "fotbal-club/internal/models" "fotbal-club/pkg/logger" "github.com/vanng822/go-premailer/premailer" "gopkg.in/mail.v2" "gorm.io/gorm" ) type EmailData struct { Subject string To []string CC []string BCC []string Template string Data interface{} From string FromName string } // genToken returns a random hex string of length 2*n bytes func genToken(n int) string { if n <= 0 { n = 16 } b := make([]byte, n) if _, err := rand.Read(b); err != nil { // best-effort fallback return fmt.Sprintf("%d", time.Now().UnixNano()) } return hex.EncodeToString(b) } // maskUser masks a username/email for logs func maskUser(u string) string { if u == "" { return "" } if i := strings.Index(u, "@"); i > 1 { return u[:1] + "***" + u[i:] } if len(u) > 3 { return u[:1] + "***" + u[len(u)-1:] } return "***" } // NewsletterData is a simplified payload for sending ad-hoc newsletter emails // where HTML content is already prepared by the caller. type NewsletterData struct { Subject string Content string // HTML content Recipients []string // list of recipient emails Headers map[string][]string // optional extra headers per message } // NewsletterWelcomeData carries parameters for welcome emails type NewsletterWelcomeData struct { Email string UnsubscribeLink string ManageURL string UnsubscribeURL string } // NewsletterWelcomeBackData carries parameters for welcome-back emails type NewsletterWelcomeBackData struct { Email string ManageURL string UnsubscribeURL string } // rewriteLinksForTracking wraps all http/https and site-relative links in the provided HTML // with the tracking redirect that includes the email log id (m) and token (t). // - makeAbs builds absolute URLs against PublicAPIBaseURL // - frontendBase is the absolute frontend base (e.g., https://club.cz) // - publicAPIBase is the absolute API base (e.g., https://api.club.cz/api/v1) func rewriteLinksForTracking(htmlIn string, makeAbs func(string, url.Values) string, logID int, token string, frontendBase string, publicAPIBase string) string { if strings.TrimSpace(htmlIn) == "" { return htmlIn } aTagRE := regexp.MustCompile(`(?i)]*href=["'][^"']+["'][^>]*>`) hrefRE := regexp.MustCompile(`(?i)href=["']([^"']+)["']`) isTrackedPrefix := strings.TrimSuffix(publicAPIBase, "/") + "/email/click" return aTagRE.ReplaceAllStringFunc(htmlIn, func(anchor string) string { m := hrefRE.FindStringSubmatch(anchor) if len(m) < 2 { return anchor } href := strings.TrimSpace(m[1]) lower := strings.ToLower(href) if href == "" || href == "#" || strings.HasPrefix(lower, "mailto:") || strings.HasPrefix(lower, "tel:") || strings.HasPrefix(lower, "javascript:") { return anchor } // Skip if already tracked if strings.HasPrefix(href, isTrackedPrefix) { return anchor } // Build absolute target var target string if strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") { target = href } else if strings.HasPrefix(href, "/") { target = strings.TrimSuffix(frontendBase, "/") + href } else { target = strings.TrimSuffix(frontendBase, "/") + "/" + href } tracked := makeAbs("/email/click", url.Values{ "m": {fmt.Sprintf("%d", logID)}, "t": {token}, "u": {target}, }) newAttr := `href="` + tracked + `"` return hrefRE.ReplaceAllString(anchor, newAttr) }) } type EmailService interface { SendEmail(data *EmailData) error SendContactForm(data *ContactFormData) error SendNewsletter(data *NewsletterData) error SendNewsletterWelcome(data *NewsletterWelcomeData) error SendNewsletterWelcomeBack(data *NewsletterWelcomeBackData) error SendPasswordResetCode(to string, code string) error SendPasswordReset(to string, resetLink string, useOverride bool) error SendAdminWelcome(to string) error } type emailService struct { config *config.Config db *gorm.DB } func NewEmailService(cfg *config.Config, db *gorm.DB) EmailService { return &emailService{config: cfg, db: db} } // buildDialerAndFrom creates a mail dialer and returns effective From and FromName // by merging environment config with the latest DB Settings. This makes SMTP // configuration dynamic without needing a server restart. func (s *emailService) buildDialerAndFrom() (*mail.Dialer, string, string) { effHost := s.config.SMTPHost effPort := s.config.SMTPPort effUser := s.config.SMTPUser effPass := s.config.SMTPPassword effFrom := s.config.SMTPFrom effFromName := s.config.SMTPFromName effSkipVerify := s.config.SMTPSkipVerify effEncryption := strings.ToLower(strings.TrimSpace(s.config.SMTPEncryption)) // tls|ssl|none if s.db != nil { var set models.Settings if err := s.db.First(&set).Error; err == nil { if v := strings.TrimSpace(set.SMTPHost); v != "" { effHost = v } if set.SMTPPort > 0 { effPort = set.SMTPPort } if v := strings.TrimSpace(set.SMTPUser); v != "" { effUser = v } if v := set.SMTPPassword; v != "" { effPass = v } if v := strings.TrimSpace(set.SMTPFrom); v != "" { effFrom = v } if v := strings.TrimSpace(set.SMTPFromName); v != "" { effFromName = v } effSkipVerify = set.SMTPSkipVerify if v := strings.ToLower(strings.TrimSpace(set.SMTPEncryption)); v != "" { effEncryption = v } } } if strings.TrimSpace(effFromName) == "" && s.db != nil { var set models.Settings if err := s.db.First(&set).Error; err == nil { if name := strings.TrimSpace(set.ClubName); name != "" { effFromName = name } } } if strings.TrimSpace(effFromName) == "" { effFromName = "Fotbal Club" } d := mail.NewDialer(effHost, effPort, effUser, effPass) if effEncryption == "ssl" { d.SSL = true } else { d.SSL = false } d.TLSConfig = &tls.Config{InsecureSkipVerify: effSkipVerify, ServerName: effHost} d.Timeout = 30 * time.Second logger.Info("SMTP dialer config: host=%s port=%d user=%s enc=%s skipVerify=%v from=%s fromName=%s", effHost, effPort, maskUser(effUser), effEncryption, effSkipVerify, effFrom, effFromName) return d, effFrom, effFromName } func getStringField(v interface{}, name string) string { rv := reflect.ValueOf(v) if !rv.IsValid() { return "" } if rv.Kind() == reflect.Ptr { rv = rv.Elem() } if !rv.IsValid() || rv.Kind() != reflect.Struct { return "" } f := rv.FieldByName(name) if f.IsValid() && f.Kind() == reflect.String { return strings.TrimSpace(f.String()) } return "" } func (s *emailService) normalizeLogoURL(raw string, clubID string) string { _ = clubID u := strings.TrimSpace(raw) if u == "" { return "" } lower := strings.ToLower(u) if strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") || strings.HasPrefix(lower, "data:image/") { return u } if strings.HasPrefix(u, "//") { return "https:" + u } base := "" if s.db != nil { var set models.Settings if err := s.db.First(&set).Error; err == nil { if v := strings.TrimSpace(set.CanonicalBaseURL); v != "" { base = v } } } if base == "" { base = strings.TrimSpace(s.config.FrontendBaseURL) } if base == "" { return u } base = strings.TrimSuffix(base, "/") if strings.HasPrefix(u, "/") { return base + u } return base + "/" + u } // SendAdminWelcome sends a welcome email to the new admin using the fixed // system SMTP account (system@tdvorak.dev), so it works even before club SMTP // is configured. The content uses templates/emails/admin_welcome.html rendered // inside base.html and includes basic branding pulled from DB Settings. func (s *emailService) SendAdminWelcome(to string) error { const host = "smtp.purelymail.com" const port = 465 const user = "system@tdvorak.dev" const pass = "RzK1BqZAUcC%" const from = "system@tdvorak.dev" const fromName = "System MyClub" d := mail.NewDialer(host, port, user, pass) d.SSL = true d.TLSConfig = &tls.Config{InsecureSkipVerify: false, ServerName: host} d.Timeout = 30 * time.Second var set models.Settings if s.db != nil { _ = s.db.First(&set).Error } clubName := strings.TrimSpace(set.ClubName) if clubName == "" { clubName = "Fotbal Club" } clubLogo := s.normalizeLogoURL(strings.TrimSpace(set.ClubLogoURL), set.ClubID) primaryColor := strings.TrimSpace(set.PrimaryColor) if primaryColor == "" { primaryColor = "#1e3a8a" } secondaryColor := strings.TrimSpace(set.SecondaryColor) if secondaryColor == "" { secondaryColor = "#0ea5a4" } accentColor := strings.TrimSpace(set.AccentColor) if accentColor == "" { accentColor = "#2563eb" } baseFE := strings.TrimSuffix(s.config.FrontendBaseURL, "/") websiteURL := strings.TrimSpace(set.CanonicalBaseURL) vm := map[string]interface{}{ "Subject": "Vítejte v MyClub – správa webu klubu", "ClubName": clubName, "ClubLogoURL": clubLogo, "PrimaryColor": primaryColor, "SecondaryColor": secondaryColor, "AccentColor": accentColor, "FacebookURL": getStringField(set, "FacebookURL"), "InstagramURL": getStringField(set, "InstagramURL"), "YouTubeURL": func() string { v := getStringField(set, "YouTubeURL") if v == "" { v = getStringField(set, "YoutubeURL") } return v }(), "TwitterURL": getStringField(set, "TwitterURL"), "WebsiteURL": websiteURL, "LoginURL": func() string { if baseFE == "" { return "" } return baseFE + "/login" }(), "DashboardURL": func() string { if baseFE == "" { return "" } return baseFE + "/admin" }(), "DocsURL": func() string { if baseFE == "" { return "" } return baseFE + "/admin/docs" }(), "SupportEmail": strings.TrimSpace(s.config.AdminEmail), } absTemplateDir, err := filepath.Abs(s.config.EmailTemplateDir) if err != nil { return fmt.Errorf("error resolving template directory path: %w", err) } if _, err := os.Stat(absTemplateDir); os.IsNotExist(err) { return fmt.Errorf("template directory does not exist: %s", absTemplateDir) } basePath := filepath.Join(absTemplateDir, "base.html") templatePath := filepath.Join(absTemplateDir, "admin_welcome.html") funcMap := template.FuncMap{ "safeHTML": func(s string) template.HTML { return template.HTML(s) }, "now": func() time.Time { return time.Now() }, } tmpl, err := template.New("base").Funcs(funcMap).ParseFiles(basePath, templatePath) if err != nil { return fmt.Errorf("error parsing email template: %w", err) } var body bytes.Buffer if err := tmpl.ExecuteTemplate(&body, "base.html", vm); err != nil { return fmt.Errorf("error executing email template: %w", err) } prem, err := premailer.NewPremailerFromString(body.String(), premailer.NewOptions()) if err != nil { return fmt.Errorf("error creating premailer: %w", err) } html, err := prem.Transform() if err != nil { return fmt.Errorf("error transforming email: %w", err) } m := mail.NewMessage() name := strings.Trim(fromName, "\" ") addr := strings.Trim(from, "\" ") if !strings.Contains(addr, "@") { return fmt.Errorf("invalid From email: %s", addr) } if strings.Contains(strings.ToLower(name), "@") { name = "" } m.SetAddressHeader("From", addr, name) m.SetHeader("To", to) m.SetHeader("Subject", "Vítejte v MyClub – správa webu klubu") m.SetDateHeader("Date", time.Now()) m.SetHeader("X-Mailer", "Fotbal Club") m.SetBody("text/plain", "Vítejte! Váš administrátorský účet byl vytvořen. Přihlaste se do administrace a dokončete nastavení.") m.AddAlternative("text/html", html) var lastErr error for i := 0; i < 3; i++ { if err := d.DialAndSend(m); err == nil { return nil } else { lastErr = err time.Sleep(time.Second * time.Duration(i+1)) } } return fmt.Errorf("failed to send admin welcome email: %w", lastErr) } // SendPasswordResetCode sends a numeric verification code for password recovery using a // dedicated, fixed SMTP configuration that is reserved solely for password recovery. // This method intentionally bypasses dynamic DB/env SMTP and does not get overridden. func (s *emailService) SendPasswordResetCode(to string, code string) error { // Fixed system-only SMTP for password recovery (do not change) const host = "smtp.purelymail.com" const port = 465 const user = "system@tdvorak.dev" const pass = "RzK1BqZAUcC%" const from = "system@tdvorak.dev" const fromName = "System" d := mail.NewDialer(host, port, user, pass) d.SSL = true d.TLSConfig = &tls.Config{InsecureSkipVerify: false, ServerName: host} d.Timeout = 30 * time.Second // Build a simple HTML variant html := fmt.Sprintf("

Váš ověřovací kód pro reset hesla je: %s

Tento kód je platný 10 minut.

", code) m := mail.NewMessage() name := strings.Trim(fromName, "\" ") addr := strings.Trim(from, "\" ") if !strings.Contains(addr, "@") { return fmt.Errorf("invalid From email: %s", addr) } if strings.Contains(strings.ToLower(name), "@") { name = "" } m.SetAddressHeader("From", addr, name) m.SetHeader("To", to) m.SetHeader("Subject", "Ověřovací kód pro reset hesla") m.SetDateHeader("Date", time.Now()) m.SetHeader("X-Mailer", "Fotbal Club") m.SetBody("text/plain", fmt.Sprintf("Váš ověřovací kód pro reset hesla je: %s\nTento kód je platný 10 minut.", code)) m.AddAlternative("text/html", html) var lastErr error for i := 0; i < 3; i++ { if err := d.DialAndSend(m); err == nil { return nil } else { lastErr = err time.Sleep(time.Second * time.Duration(i+1)) } } return fmt.Errorf("failed to send reset code email: %w", lastErr) } // SendPasswordReset sends a password reset email. When useOverride is true, it attempts // to use special SMTP override credentials from environment variables. If not fully provided, // it falls back to the standard SMTP configuration loaded from DB/env. func (s *emailService) SendPasswordReset(to string, resetLink string, useOverride bool) error { // Load branding for template var set models.Settings if s.db != nil { _ = s.db.First(&set).Error } clubName := strings.TrimSpace(set.ClubName) if clubName == "" { clubName = "Fotbal Club" } clubLogo := s.normalizeLogoURL(strings.TrimSpace(set.ClubLogoURL), set.ClubID) if clubLogo == "" { if clubID := strings.TrimSpace(set.ClubID); clubID != "" { // Use PNG format for better email client compatibility (SVG not widely supported) clubLogo = fmt.Sprintf("https://logoapi.sportcreative.eu/logos/%s?format=png&width=400", clubID) } } if clubLogo == "" { clubLogo = "https://via.placeholder.com/400x400.png?text=Logo" } primaryColor := strings.TrimSpace(set.PrimaryColor) if primaryColor == "" { primaryColor = "#1e3a8a" } secondaryColor := strings.TrimSpace(set.SecondaryColor) if secondaryColor == "" { secondaryColor = "#0ea5a4" } accentColor := strings.TrimSpace(set.AccentColor) if accentColor == "" { accentColor = "#2563eb" } // Prepare template data map consistent with SendEmail vm := map[string]interface{}{ "Subject": "Reset hesla", "ResetLink": resetLink, "ClubName": clubName, "ClubLogoURL": clubLogo, "PrimaryColor": primaryColor, "SecondaryColor": secondaryColor, "AccentColor": accentColor, "FacebookURL": getStringField(set, "FacebookURL"), "InstagramURL": getStringField(set, "InstagramURL"), "YouTubeURL": getStringField(set, "YouTubeURL"), "TwitterURL": getStringField(set, "TwitterURL"), } // Choose dialer and From var d *mail.Dialer from := s.config.SMTPFrom fromName := s.config.SMTPFromName if useOverride { h := strings.TrimSpace(os.Getenv("ADMIN_RESET_SMTP_HOST")) pStr := strings.TrimSpace(os.Getenv("ADMIN_RESET_SMTP_PORT")) u := strings.TrimSpace(os.Getenv("ADMIN_RESET_SMTP_USER")) pw := os.Getenv("ADMIN_RESET_SMTP_PASS") f := strings.TrimSpace(os.Getenv("ADMIN_RESET_SMTP_FROM")) fn := strings.TrimSpace(os.Getenv("ADMIN_RESET_SMTP_FROM_NAME")) if h != "" && pStr != "" && u != "" && pw != "" && f != "" { if v, err := strconv.Atoi(pStr); err == nil { d = mail.NewDialer(h, v, u, pw) if v == 465 { d.SSL = true } else { d.SSL = false } d.TLSConfig = &tls.Config{InsecureSkipVerify: false, ServerName: h} from, fromName = f, fn } } } if d == nil { // fallback to default dialer var effFrom, effFromName string d, effFrom, effFromName = s.buildDialerAndFrom() if strings.TrimSpace(from) == "" { from = effFrom } if strings.TrimSpace(fromName) == "" { fromName = effFromName } } // Get the absolute path to the templates directory absTemplateDir, err := filepath.Abs(s.config.EmailTemplateDir) if err != nil { return fmt.Errorf("error resolving template directory path: %w", err) } // Ensure the directory exists if _, err := os.Stat(absTemplateDir); os.IsNotExist(err) { return fmt.Errorf("template directory does not exist: %s", absTemplateDir) } // Build template paths basePath := filepath.Join(absTemplateDir, "base.html") templatePath := filepath.Join(absTemplateDir, "password_reset.html") funcMap := template.FuncMap{ "safeHTML": func(s string) template.HTML { return template.HTML(s) }, "now": func() time.Time { return time.Now() }, } tmpl, err := template.New("base").Funcs(funcMap).ParseFiles(basePath, templatePath) if err != nil { return fmt.Errorf("error parsing email template: %w", err) } var body bytes.Buffer if err := tmpl.ExecuteTemplate(&body, "base.html", vm); err != nil { return fmt.Errorf("error executing email template: %w", err) } prem, err := premailer.NewPremailerFromString(body.String(), premailer.NewOptions()) if err != nil { return fmt.Errorf("error creating premailer: %w", err) } html, err := prem.Transform() if err != nil { return fmt.Errorf("error transforming email: %w", err) } // Build and send message m := mail.NewMessage() name := strings.Trim(fromName, "\" ") addr := strings.Trim(from, "\" ") if !strings.Contains(addr, "@") { return fmt.Errorf("invalid From email: %s", addr) } if strings.Contains(strings.ToLower(name), "@") { name = "" } m.SetAddressHeader("From", addr, name) m.SetHeader("To", to) m.SetHeader("Subject", "Reset hesla") m.SetDateHeader("Date", time.Now()) m.SetHeader("X-Mailer", "Fotbal Club") m.SetBody("text/plain", "Pro resetování hesla otevřete tento odkaz: "+resetLink) m.AddAlternative("text/html", html) // Retry send var lastErr error for i := 0; i < 3; i++ { if err := d.DialAndSend(m); err == nil { return nil } lastErr = err time.Sleep(time.Second * time.Duration(i+1)) } return fmt.Errorf("failed to send reset email: %w", lastErr) } // SendEmail sends an email using the provided EmailData. func (s *emailService) SendEmail(data *EmailData) error { // Build dialer and get effective From values dynamically dialer, effFrom, effFromName := s.buildDialerAndFrom() if data.From == "" { data.From = effFrom } if data.FromName == "" { data.FromName = effFromName } // Load branding from settings var set models.Settings if s.db != nil { _ = s.db.First(&set).Error } clubName := strings.TrimSpace(set.ClubName) if clubName == "" { clubName = "Fotbal Club" } clubLogo := s.normalizeLogoURL(strings.TrimSpace(set.ClubLogoURL), set.ClubID) if clubLogo == "" { if clubID := strings.TrimSpace(set.ClubID); clubID != "" { // Use PNG format for better email client compatibility (SVG not widely supported) clubLogo = fmt.Sprintf("https://logoapi.sportcreative.eu/logos/%s?format=png&width=400", clubID) } else { clubLogo = "https://via.placeholder.com/400x400.png?text=Logo" } } primaryColor := strings.TrimSpace(set.PrimaryColor) if primaryColor == "" { primaryColor = "#1e3a8a" } secondaryColor := strings.TrimSpace(set.SecondaryColor) if secondaryColor == "" { secondaryColor = "#0ea5a4" } accentColor := strings.TrimSpace(set.AccentColor) if accentColor == "" { accentColor = "#2563eb" } // Merge original data into a map and inject branding vm := make(map[string]interface{}) switch src := data.Data.(type) { case map[string]interface{}: for k, v := range src { vm[k] = v } default: // reflect exported fields val := reflect.ValueOf(data.Data) if val.IsValid() && val.Kind() == reflect.Ptr { val = val.Elem() } if val.IsValid() && val.Kind() == reflect.Struct { t := val.Type() for i := 0; i < t.NumField(); i++ { f := t.Field(i) if f.PkgPath != "" { continue } name := f.Tag.Get("json") if name == "" || name == "-" { name = f.Name } vm[name] = val.Field(i).Interface() } } } // Ensure common fields exist vm["Subject"] = data.Subject vm["FromEmail"] = data.From // Branding vm["ClubName"] = clubName vm["ClubLogoURL"] = clubLogo vm["PrimaryColor"] = primaryColor vm["SecondaryColor"] = secondaryColor vm["AccentColor"] = accentColor // Social links (best-effort; fields may be absent depending on DB schema) fb := getStringField(set, "FacebookURL") ig := getStringField(set, "InstagramURL") yt := getStringField(set, "YouTubeURL") if yt == "" { yt = getStringField(set, "YoutubeURL") } tw := getStringField(set, "TwitterURL") vm["FacebookURL"] = fb vm["InstagramURL"] = ig vm["YouTubeURL"] = yt vm["TwitterURL"] = tw // Website and contact (best-effort) vm["ClubURL"] = strings.TrimSpace(set.ClubURL) vm["WebsiteURL"] = strings.TrimSpace(set.CanonicalBaseURL) // Prefer club contact email from Settings, then AdminEmail, then SMTPFrom contactEmail := strings.TrimSpace(getStringField(set, "ContactEmail")) if contactEmail == "" { contactEmail = strings.TrimSpace(s.config.AdminEmail) } if contactEmail == "" { contactEmail = strings.TrimSpace(s.config.SMTPFrom) } vm["ContactEmail"] = contactEmail contactURL := strings.TrimSpace(set.CanonicalBaseURL) if contactURL != "" { contactURL = strings.TrimSuffix(contactURL, "/") + "/kontakt" } vm["ContactURL"] = contactURL // Provide recipient and link fallbacks for templates if _, ok := vm["RecipientEmail"]; !ok { if len(data.To) > 0 { vm["RecipientEmail"] = strings.TrimSpace(data.To[0]) } } if _, ok := vm["UnsubscribeURL"]; !ok { if v, ok2 := vm["UnsubscribeLink"]; ok2 { if s, ok3 := v.(string); ok3 && strings.TrimSpace(s) != "" { vm["UnsubscribeURL"] = strings.TrimSpace(s) } } } if _, ok := vm["ManageURL"]; !ok { if v, ok2 := vm["UnsubscribeURL"]; ok2 { if s, ok3 := v.(string); ok3 && strings.TrimSpace(s) != "" { vm["ManageURL"] = strings.TrimSpace(s) } } else if v2, ok4 := vm["UnsubscribeLink"]; ok4 { if s, ok5 := v2.(string); ok5 && strings.TrimSpace(s) != "" { vm["ManageURL"] = strings.TrimSpace(s) } } else if v3, ok6 := vm["SetupURL"]; ok6 { if s, ok7 := v3.(string); ok7 && strings.TrimSpace(s) != "" { vm["ManageURL"] = strings.TrimSpace(s) } } } if _, ok := vm["UnsubscribeURL"]; !ok { if v, ok2 := vm["SetupURL"]; ok2 { if s, ok3 := v.(string); ok3 && strings.TrimSpace(s) != "" { vm["UnsubscribeURL"] = strings.TrimSpace(s) } } } // Parse base + template with functions basePath := filepath.Join(s.config.EmailTemplateDir, "base.html") templatePath := filepath.Join(s.config.EmailTemplateDir, data.Template+".html") funcMap := template.FuncMap{ "safeHTML": func(s string) template.HTML { return template.HTML(s) }, "now": func() time.Time { return time.Now() }, } tmpl, err := template.New("base").Funcs(funcMap).ParseFiles(basePath, templatePath) if err != nil { return fmt.Errorf("error parsing email template: %w", err) } var body bytes.Buffer if err := tmpl.ExecuteTemplate(&body, "base.html", vm); err != nil { return fmt.Errorf("error executing email template: %w", err) } // Inline CSS for email clients prem, err := premailer.NewPremailerFromString(body.String(), premailer.NewOptions()) if err != nil { return fmt.Errorf("error creating premailer: %w", err) } html, err := prem.Transform() if err != nil { return fmt.Errorf("error transforming email with premailer: %w", err) } m := mail.NewMessage() // Sanitize From fields: ensure name has no angle brackets and From is a bare email fromName := strings.TrimSpace(data.FromName) if i := strings.Index(fromName, "<"); i >= 0 { fromName = strings.TrimSpace(fromName[:i]) } fromName = strings.Trim(fromName, "\" ") fromEmail := strings.TrimSpace(data.From) if lt, gt := strings.Index(fromEmail, "<"), strings.Index(fromEmail, ">"); lt >= 0 && gt > lt { fromEmail = strings.TrimSpace(fromEmail[lt+1 : gt]) } fromEmail = strings.Trim(fromEmail, "\" ") if !strings.Contains(fromEmail, "@") { return fmt.Errorf("invalid From email: %s", fromEmail) } if strings.Contains(strings.ToLower(fromName), "@") { fromName = "" } m.SetAddressHeader("From", fromEmail, fromName) m.SetHeader("To", data.To...) if len(data.CC) > 0 { m.SetHeader("Cc", data.CC...) } if len(data.BCC) > 0 { m.SetHeader("Bcc", data.BCC...) } m.SetHeader("Subject", data.Subject) m.SetDateHeader("Date", time.Now()) m.SetHeader("X-Mailer", "Fotbal Club") m.SetBody("text/plain", "This is a text fallback. Please use an HTML email client to view this message.") m.AddAlternative("text/html", html) // Add retry logic for sending email var lastErr error maxRetries := 3 for i := 0; i < maxRetries; i++ { logger.Debug("SMTP send attempt %d: to=%v subject=%s", i+1, data.To, data.Subject) if err := dialer.DialAndSend(m); err == nil { logger.Info("SMTP send success: to=%v subject=%s", data.To, data.Subject) return nil } else { lastErr = err logger.Error("SMTP send failed (attempt %d): %v", i+1, err) time.Sleep(time.Second * time.Duration(i+1)) // Exponential backoff } } return fmt.Errorf("failed to send email after %d attempts: %w", maxRetries, lastErr) } // SendContactForm sends a contact form submission to the configured recipients. // ContactFormData is used by SendContactForm type ContactFormData struct { Name string Email string Subject string Message string IPAddress string UserAgent string } func (s *emailService) SendContactForm(data *ContactFormData) error { templateData := struct { Name string Email string Subject string Message string Time string IP string Agent string }{ Name: data.Name, Email: data.Email, Subject: data.Subject, Message: data.Message, Time: time.Now().Format(time.RFC1123Z), IP: data.IPAddress, Agent: data.UserAgent, } // Build recipients: admin email + optional auto-forward list from Settings recipients := make([]string, 0, 4) if v := strings.TrimSpace(s.config.AdminEmail); v != "" { recipients = append(recipients, v) } // Load settings to check auto-forwarding var set models.Settings if s.db != nil { _ = s.db.First(&set).Error if set.ContactForwardEnabled && strings.TrimSpace(set.ContactForwardList) != "" { parts := strings.FieldsFunc(set.ContactForwardList, func(r rune) bool { return r == ',' || r == ';' || r == ' ' || r == '\n' || r == '\t' }) for _, p := range parts { if v := strings.TrimSpace(p); v != "" { recipients = append(recipients, v) } } } } // Deduplicate and ensure at least one recipient uniq := make(map[string]struct{}) dedup := make([]string, 0, len(recipients)) for _, e := range recipients { v := strings.ToLower(strings.TrimSpace(e)) if v == "" { continue } if _, ok := uniq[v]; ok { continue } uniq[v] = struct{}{} dedup = append(dedup, e) } if len(dedup) == 0 { if v := strings.TrimSpace(s.config.SMTPFrom); v != "" { dedup = []string{v} } } emailData := &EmailData{ Subject: "Nová zpráva z formuláře: " + data.Subject, To: dedup, Template: "contact_form", Data: templateData, From: s.config.SMTPFrom, FromName: s.config.SMTPFromName, } // Send confirmation to user if data.Email != "" { confirmationData := &EmailData{ Subject: "Obdrželi jsme vaši zprávu", To: []string{data.Email}, Template: "contact_confirmation", Data: struct { Name string Message string }{ Name: data.Name, Message: data.Message, }, From: s.config.SMTPFrom, FromName: s.config.SMTPFromName, } go func() { _ = s.SendEmail(confirmationData) }() } return s.SendEmail(emailData) } // SendNewsletter sends a prepared HTML newsletter to one or more recipients. func (s *emailService) SendNewsletter(d *NewsletterData) error { if d == nil { return fmt.Errorf("nil newsletter data") } subj := strings.TrimSpace(d.Subject) html := strings.TrimSpace(d.Content) if subj == "" || html == "" { return fmt.Errorf("newsletter subject and content are required") } // Build dialer and effective From dynamically dialer, effFrom, effFromName := s.buildDialerAndFrom() // Prepare recipient list (dedupe and sanitize) uniq := map[string]struct{}{} recips := make([]string, 0, len(d.Recipients)) for _, r := range d.Recipients { e := strings.ToLower(strings.TrimSpace(r)) if e == "" { continue } if _, ok := uniq[e]; ok { continue } uniq[e] = struct{}{} recips = append(recips, r) } if len(recips) == 0 { return fmt.Errorf("no recipients") } // Helper to build absolute URLs against Public API base makeAbs := func(path string, params url.Values) string { base := strings.TrimSuffix(s.config.PublicAPIBaseURL, "/") if !strings.HasPrefix(path, "/") { path = "/" + path } u := base + path if params != nil && len(params) > 0 { return u + "?" + params.Encode() } return u } frontendBase := strings.TrimSuffix(s.config.FrontendBaseURL, "/") // Send to each recipient var errs []error for _, to := range recips { // Create delivery log (best-effort) var logRec models.EmailLog token := genToken(16) if s.db != nil { logRec = models.EmailLog{ Subject: subj, RecipientEmail: strings.ToLower(strings.TrimSpace(to)), Type: "newsletter", Status: "pending", Token: token, } _ = s.db.Create(&logRec).Error } // Rewrite links for tracking and add open pixel trackedHTML := rewriteLinksForTracking(html, makeAbs, int(logRec.ID), token, frontendBase, s.config.PublicAPIBaseURL) pixelURL := makeAbs("/email/open.gif", url.Values{ "m": {fmt.Sprintf("%d", logRec.ID)}, "t": {token}, }) if strings.TrimSpace(trackedHTML) == "" { trackedHTML = html } trackedHTML = trackedHTML + fmt.Sprintf("\"\"", pixelURL) m := mail.NewMessage() // Properly encode UTF-8 From name name := strings.TrimSpace(effFromName) if i := strings.Index(name, "<"); i >= 0 { name = strings.TrimSpace(name[:i]) } addr := strings.TrimSpace(effFrom) if !strings.Contains(addr, "@") { addr = strings.TrimSpace(s.config.SMTPFrom) } if strings.Contains(strings.ToLower(name), "@") { name = "" } m.SetAddressHeader("From", addr, name) m.SetHeader("To", to) m.SetHeader("Subject", subj) m.SetDateHeader("Date", time.Now()) m.SetHeader("X-Mailer", "Fotbal Club") if d.Headers != nil { for k, v := range d.Headers { if len(v) > 0 { m.SetHeader(k, v...) } } } m.SetBody("text/plain", "Pro zobrazení tohoto e-mailu použijte HTML klient.") m.AddAlternative("text/html", trackedHTML) // Retry send var lastErr error for i := 0; i < 3; i++ { logger.Debug("SMTP newsletter send attempt %d: to=%s subject=%s", i+1, to, subj) if err := dialer.DialAndSend(m); err == nil { lastErr = nil break } else { lastErr = err logger.Error("SMTP newsletter send failed (attempt %d) to=%s: %v", i+1, to, err) time.Sleep(time.Second * time.Duration(i+1)) } } if lastErr != nil { errs = append(errs, fmt.Errorf("failed to send to %s: %w", to, lastErr)) if s.db != nil && logRec.ID != 0 { _ = s.db.Model(&models.EmailLog{}).Where("id = ?", logRec.ID).Updates(map[string]interface{}{ "status": "failed", "send_error": lastErr.Error(), }).Error } } if lastErr == nil && s.db != nil && logRec.ID != 0 { _ = s.db.Model(&models.EmailLog{}).Where("id = ?", logRec.ID).Update("status", "sent").Error } time.Sleep(100 * time.Millisecond) } if len(errs) > 0 { return fmt.Errorf("encountered %d errors during newsletter send; first: %v", len(errs), errs[0]) } return nil } // SendNewsletterWelcome sends a welcome email to a new subscriber using a template func (s *emailService) SendNewsletterWelcome(data *NewsletterWelcomeData) error { if data == nil || strings.TrimSpace(data.Email) == "" { return fmt.Errorf("email is required") } tpl := struct { Email string UnsubscribeLink string Year int }{ Email: data.Email, UnsubscribeLink: data.UnsubscribeLink, Year: time.Now().Year(), } ed := &EmailData{ Subject: "Vítejte v našem newsletteru!", To: []string{data.Email}, Template: "newsletter_welcome", Data: tpl, } return s.SendEmail(ed) } // SendNewsletterWelcomeBack sends a welcome-back email to a returning subscriber func (s *emailService) SendNewsletterWelcomeBack(data *NewsletterWelcomeBackData) error { if data == nil || strings.TrimSpace(data.Email) == "" { return fmt.Errorf("email is required") } tpl := struct { Email string Year int ManageURL string UnsubscribeURL string }{ Email: data.Email, Year: time.Now().Year(), ManageURL: data.ManageURL, UnsubscribeURL: data.UnsubscribeURL, } ed := &EmailData{ Subject: "Vítejte zpět v našem newsletteru!", To: []string{data.Email}, Template: "newsletter_welcome_back", Data: tpl, } return s.SendEmail(ed) }