Files
MyClub/pkg/email/service.go
T
Tomas Dvorak 823fabee02 de day #74
2025-10-28 22:38:27 +01:00

1232 lines
39 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"
"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)<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
}
// 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
}
}
}
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)
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
// 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,
}
emailData := &EmailData{
Subject: "New Contact Form: " + data.Subject,
To: []string{s.config.AdminEmail},
Template: "contact_form",
Data: templateData,
From: s.config.SMTPFrom,
FromName: s.config.SMTPFromName,
}
// Send confirmation to user
if data.Email != "" {
confirmationData := &EmailData{
Subject: "We've received your message",
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
}{
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}})
}(),
}
// 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("<mailto:%s?subject=Unsubscribe>", 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 + " Newsletter"
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("<mailto:%s?subject=Unsubscribe>", 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
}