Files
MyClub/pkg/email/service.go
T
Tomas Dvorak f3db65d350 dev day #90 🥳
2025-11-12 20:31:37 +01:00

1083 lines
34 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"
"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)<a\b[^>]*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
}
// 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 ""
}
// 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 := strings.TrimSpace(set.ClubLogoURL)
if clubLogo == "" {
if clubID := strings.TrimSpace(set.ClubID); clubID != "" {
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)
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("<p>Váš ověřovací kód pro reset hesla je: <strong>%s</strong></p><p>Tento kód je platný 10 minut.</p>", 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 := 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.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 := 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.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("<img src=\"%s\" width=\"1\" height=\"1\" style=\"display:none;\" alt=\"\" />", 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)
}