package email import ( "bytes" "crypto/rand" "crypto/tls" "encoding/hex" "fmt" "html/template" "net/url" "os" "path/filepath" "regexp" "reflect" "strconv" "strings" "time" "fotbal-club/internal/config" "fotbal-club/internal/models" "fotbal-club/pkg/logger" "fotbal-club/pkg/utils" "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 } // 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 } // 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 { // Fixed system-only SMTP (same as password recovery) 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 // Branding/settings (best-effort) var set models.Settings if s.db != nil { _ = s.db.First(&set).Error } clubName := strings.TrimSpace(set.ClubName) if clubName == "" { clubName = "Fotbal Club" } clubLogo := strings.TrimSpace(set.ClubLogoURL) 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" } baseFE := strings.TrimSuffix(s.config.FrontendBaseURL, "/") websiteURL := strings.TrimSpace(set.CanonicalBaseURL) // Template variables 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"), // Links used by base template buttons/sections "WebsiteURL": websiteURL, // Convenience links used in the admin welcome content "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.SetHeader("From", fmt.Sprintf("%s <%s>", name, addr)) 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 } 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 // Load branding for template (best-effort) var set models.Settings if s.db != nil { _ = s.db.First(&set).Error } clubName := strings.TrimSpace(set.ClubName) if clubName == "" { clubName = "Fotbal Club" } clubLogo := strings.TrimSpace(set.ClubLogoURL) 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 vm := map[string]interface{}{ "Subject": "Ověřovací kód pro reset hesla", "Code": code, "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"), } // Resolve template paths absTemplateDir, err := filepath.Abs(s.config.EmailTemplateDir) if err != nil { return fmt.Errorf("error resolving template directory path: %w", err) } basePath := filepath.Join(absTemplateDir, "base.html") templatePath := filepath.Join(absTemplateDir, "password_reset_code.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.SetHeader("From", fmt.Sprintf("%s <%s>", name, addr)) 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 } 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 := strings.TrimSpace(set.ClubLogoURL) 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.SetHeader("From", fmt.Sprintf("%s <%s>", name, addr)) 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) } // ContactFormData is used by SendContactForm type ContactFormData struct { Name string Email string Subject string Message string IPAddress string UserAgent string } func NewEmailService(cfg *config.Config, db *gorm.DB) EmailService { // Keep a lightweight service; the dialer will be built dynamically per send from DB Settings. 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) { // Start from env-backed config 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 // Overlay with Settings from DB if present 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 } } } // Fallback FromName to club name when not configured 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 } // Provide ServerName for proper certificate verification when not skipping d.TLSConfig = &tls.Config{InsecureSkipVerify: effSkipVerify, ServerName: effHost} // Allow more time for providers that are slow to accept connections/auth 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 } // 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 "***" } // getStringField tries to read a string struct field by name using reflection. // Returns empty string if not found or not a string. 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) SendNewsletterWelcome(data *NewsletterWelcomeData) error { templateData := struct { Email string UnsubscribeLink string Year int }{ Email: data.Email, UnsubscribeLink: data.UnsubscribeLink, Year: time.Now().Year(), } emailData := &EmailData{ Subject: "Vítejte v našem newsletteru!", To: []string{data.Email}, Template: "newsletter_welcome", Data: templateData, } return s.SendEmail(emailData) } func (s *emailService) SendNewsletterWelcomeBack(data *NewsletterWelcomeBackData) error { templateData := struct { Email string Year int ManageURL string UnsubscribeURL string }{ Email: data.Email, Year: time.Now().Year(), ManageURL: data.ManageURL, UnsubscribeURL: data.UnsubscribeURL, } emailData := &EmailData{ Subject: "Vítejte zpět v našem newsletteru!", To: []string{data.Email}, Template: "newsletter_welcome_back", Data: templateData, } return s.SendEmail(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 := strings.TrimSpace(set.ClubLogoURL) 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.SetHeader("From", fmt.Sprintf("%s <%s>", fromName, fromEmail)) 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) } 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) } type NewsletterData struct { Subject string Content string Recipients []string } type NewsletterWelcomeData struct { Email string UnsubscribeLink string } type NewsletterWelcomeBackData struct { Email string ManageURL string UnsubscribeURL string } func (s *emailService) SendNewsletter(data *NewsletterData) error { if len(data.Recipients) == 0 { return fmt.Errorf("no recipients specified") } var errs []error // Load club settings once per send var set models.Settings if s.db != nil { _ = s.db.First(&set).Error // ignore error; we'll fallback to defaults } clubName := strings.TrimSpace(set.ClubName) if clubName == "" { clubName = "Fotbal Club" } clubLogo := strings.TrimSpace(set.ClubLogoURL) 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" } // Colors with sensible defaults primaryColor := strings.TrimSpace(set.PrimaryColor) if primaryColor == "" { primaryColor = "#1e3a8a" // blue-800 } secondaryColor := strings.TrimSpace(set.SecondaryColor) if secondaryColor == "" { secondaryColor = "#0ea5a4" // teal-500 } accentColor := strings.TrimSpace(set.AccentColor) if accentColor == "" { accentColor = "#2563eb" // blue-600 } // Helper to build absolute tracking URLs based on PublicAPIBaseURL (backend origin) makeAbs := func(path string, q url.Values) string { base := strings.TrimSuffix(s.config.PublicAPIBaseURL, "/") u := base + path if len(q) > 0 { u = u + "?" + q.Encode() } return u } // Helper to create a random hex token newToken := func() string { var b [16]byte if _, err := rand.Read(b[:]); err != nil { return fmt.Sprintf("%d", time.Now().UnixNano()) } return hex.EncodeToString(b[:]) } // Send to each recipient individually with BCC protection for _, recipient := range data.Recipients { // Personalized subscriber token & links subsToken, _ := utils.GenerateSubscriberToken(recipient, 60*24*30) // 30 days baseFE := strings.TrimSuffix(s.config.FrontendBaseURL, "/") manageURL := baseFE + "/newsletter/setup?token=" + url.QueryEscape(subsToken) // Unsubscribe goes to the same preferences page where user can disable all unsubscribePath := baseFE + "/newsletter/preferences?token=" + url.QueryEscape(subsToken) // Site/home URL and contact info siteURL := strings.TrimSpace(set.CanonicalBaseURL) clubURL := strings.TrimSpace(set.ClubURL) contactEmail := strings.TrimSpace(s.config.AdminEmail) if contactEmail == "" { contactEmail = strings.TrimSpace(s.config.SMTPFrom) } var contactURL string if siteURL != "" { contactURL = strings.TrimSuffix(siteURL, "/") + "/kontakt" } // Create log + token for tracking trackTok := newToken() elog := &models.EmailLog{ Subject: data.Subject, RecipientEmail: recipient, Type: "newsletter", Status: "sent", // we consider it sent when SMTP succeeds, but tracking URLs need id/token now Token: trackTok, } if s.db != nil { _ = s.db.Create(elog).Error } // Build template data including the UnsubscribeURL and OpenPixelURL personalTemplateData := struct { Subject string Content string Date string UnsubscribeURL string ManageURL string ClubName string ClubLogoURL string PrimaryColor string SecondaryColor string AccentColor string FacebookURL string InstagramURL string YouTubeURL string TwitterURL string OpenPixelURL string WebsiteURL string ClubURL string ContactEmail string ContactURL string RecipientEmail string }{ Subject: data.Subject, Content: data.Content, Date: time.Now().Format("02. 01. 2006"), UnsubscribeURL: makeAbs("/email/click", url.Values{"m": {fmt.Sprintf("%d", elog.ID)}, "t": {trackTok}, "u": {unsubscribePath}}), ManageURL: makeAbs("/email/click", url.Values{"m": {fmt.Sprintf("%d", elog.ID)}, "t": {trackTok}, "u": {manageURL}}), ClubName: clubName, ClubLogoURL: clubLogo, PrimaryColor: primaryColor, SecondaryColor: secondaryColor, AccentColor: accentColor, // socials assigned below OpenPixelURL: makeAbs("/email/open.gif", url.Values{"m": {fmt.Sprintf("%d", elog.ID)}, "t": {trackTok}}), WebsiteURL: func() string { if siteURL == "" { return "" } return makeAbs("/email/click", url.Values{"m": {fmt.Sprintf("%d", elog.ID)}, "t": {trackTok}, "u": {siteURL}}) }(), ClubURL: func() string { if clubURL == "" { return "" } return makeAbs("/email/click", url.Values{"m": {fmt.Sprintf("%d", elog.ID)}, "t": {trackTok}, "u": {clubURL}}) }(), ContactEmail: contactEmail, ContactURL: func() string { if contactURL == "" { return "" } return makeAbs("/email/click", url.Values{"m": {fmt.Sprintf("%d", elog.ID)}, "t": {trackTok}, "u": {contactURL}}) }(), RecipientEmail: recipient, } // Wrap socials if present if v := getStringField(set, "FacebookURL"); v != "" { personalTemplateData.FacebookURL = makeAbs("/email/click", url.Values{"m": {fmt.Sprintf("%d", elog.ID)}, "t": {trackTok}, "u": {v}}) } if v := getStringField(set, "InstagramURL"); v != "" { personalTemplateData.InstagramURL = makeAbs("/email/click", url.Values{"m": {fmt.Sprintf("%d", elog.ID)}, "t": {trackTok}, "u": {v}}) } if v := getStringField(set, "YouTubeURL"); v != "" { personalTemplateData.YouTubeURL = makeAbs("/email/click", url.Values{"m": {fmt.Sprintf("%d", elog.ID)}, "t": {trackTok}, "u": {v}}) } if personalTemplateData.YouTubeURL == "" { if v := getStringField(set, "YoutubeURL"); v != "" { personalTemplateData.YouTubeURL = makeAbs("/email/click", url.Values{"m": {fmt.Sprintf("%d", elog.ID)}, "t": {trackTok}, "u": {v}}) } } if v := getStringField(set, "TwitterURL"); v != "" { personalTemplateData.TwitterURL = makeAbs("/email/click", url.Values{"m": {fmt.Sprintf("%d", elog.ID)}, "t": {trackTok}, "u": {v}}) } // Rewrite links in Content to include tracking (per recipient) frontendBase := strings.TrimSuffix(s.config.FrontendBaseURL, "/") publicAPIBase := strings.TrimSuffix(s.config.PublicAPIBaseURL, "/") if strings.TrimSpace(personalTemplateData.Content) != "" { personalTemplateData.Content = rewriteLinksForTracking(personalTemplateData.Content, makeAbs, int(elog.ID), trackTok, frontendBase, publicAPIBase) } emailData := &EmailData{ Subject: data.Subject, To: []string{recipient}, Template: "newsletter", Data: personalTemplateData, // From fields will be filled using effective config below } // Add List-Unsubscribe header (mailto and HTTPS URL) headers := map[string][]string{ "List-Unsubscribe": {fmt.Sprintf("", s.config.SMTPFrom), fmt.Sprintf("<%s>", unsubscribePath)}, } // Build dialer and effective From dynamically on each send dialer, effFrom, effFromName := s.buildDialerAndFrom() m := mail.NewMessage() // Build From with sanitized values; prefer emailData overrides if provided rawName := effFromName if strings.TrimSpace(emailData.FromName) != "" { rawName = emailData.FromName } name := strings.TrimSpace(rawName) if i := strings.Index(name, "<"); i >= 0 { name = strings.TrimSpace(name[:i]) } name = strings.Trim(name, "\" ") rawEmail := effFrom if strings.TrimSpace(emailData.From) != "" { rawEmail = emailData.From } addr := strings.TrimSpace(rawEmail) if lt, gt := strings.Index(addr, "<"), strings.Index(addr, ">"); lt >= 0 && gt > lt { addr = strings.TrimSpace(addr[lt+1 : gt]) } addr = strings.Trim(addr, "\" ") if !strings.Contains(addr, "@") { return fmt.Errorf("invalid From email: %s", addr) } if strings.Contains(strings.ToLower(name), "@") { name = "" } m.SetHeader("From", fmt.Sprintf("%s <%s>", name, addr)) m.SetHeader("To", emailData.To...) m.SetHeader("Subject", emailData.Subject) m.SetHeader("List-Unsubscribe", fmt.Sprintf("", addr)) m.SetDateHeader("Date", time.Now()) // Add HTML and text versions basePath := filepath.Join(s.config.EmailTemplateDir, "base.html") templatePath := filepath.Join(s.config.EmailTemplateDir, emailData.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 { errs = append(errs, fmt.Errorf("error parsing template for %s: %w", recipient, err)) continue } var body bytes.Buffer // Execute the base layout which includes {{template "content" .}} if err := tmpl.ExecuteTemplate(&body, "base.html", emailData.Data); err != nil { errs = append(errs, fmt.Errorf("error executing template for %s: %w", recipient, err)) continue } prem, err := premailer.NewPremailerFromString(body.String(), premailer.NewOptions()) if err != nil { errs = append(errs, fmt.Errorf("error creating premailer for %s: %w", recipient, err)) continue } html, err := prem.Transform() if err != nil { errs = append(errs, fmt.Errorf("error transforming email for %s: %w", recipient, err)) continue } m.SetBody("text/plain", "Please view this email in an HTML email client.") m.AddAlternative("text/html", html) // Set custom headers for k, v := range headers { m.SetHeader(k, v...) } // Send with retry var lastErr error maxRetries := 2 for i := 0; i < maxRetries; i++ { logger.Debug("SMTP newsletter send attempt %d: to=%s subject=%s", i+1, recipient, emailData.Subject) if err := dialer.DialAndSend(m); err == nil { lastErr = nil logger.Info("SMTP newsletter send success: to=%s subject=%s", recipient, emailData.Subject) break } else { lastErr = err logger.Error("SMTP newsletter send failed (attempt %d) to=%s: %v", i+1, recipient, err) time.Sleep(time.Second * time.Duration(i+1)) } } if lastErr != nil { errs = append(errs, fmt.Errorf("failed to send to %s: %w", recipient, lastErr)) if s.db != nil { _ = s.db.Model(&models.EmailLog{}).Where("id = ?", elog.ID).Updates(map[string]any{"status": "failed", "send_error": lastErr.Error()}).Error } } // Rate limiting time.Sleep(100 * time.Millisecond) } if len(errs) > 0 { return fmt.Errorf("encountered %d errors while sending newsletter: %v", len(errs), errs) } return nil }