This commit is contained in:
Tomas Dvorak
2025-11-14 15:53:12 +01:00
parent f3db65d350
commit c941313fd5
149 changed files with 4366 additions and 12935 deletions
+226 -187
View File
@@ -10,8 +10,8 @@ import (
"net/url"
"os"
"path/filepath"
"regexp"
"reflect"
"regexp"
"strconv"
"strings"
"time"
@@ -38,13 +38,15 @@ type EmailData struct {
// 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)
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
@@ -65,8 +67,8 @@ func maskUser(u string) string {
// where HTML content is already prepared by the caller.
type NewsletterData struct {
Subject string
Content string // HTML content
Recipients []string // list of recipient emails
Content string // HTML content
Recipients []string // list of recipient emails
Headers map[string][]string // optional extra headers per message
}
@@ -216,8 +218,6 @@ func (s *emailService) buildDialerAndFrom() (*mail.Dialer, string, string) {
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() {
@@ -236,6 +236,41 @@ func getStringField(v interface{}, name string) string {
return ""
}
func (s *emailService) normalizeLogoURL(raw string, clubID string) string {
_ = clubID
u := strings.TrimSpace(raw)
if u == "" {
return ""
}
lower := strings.ToLower(u)
if strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") || strings.HasPrefix(lower, "data:image/") {
return u
}
if strings.HasPrefix(u, "//") {
return "https:" + u
}
base := ""
if s.db != nil {
var set models.Settings
if err := s.db.First(&set).Error; err == nil {
if v := strings.TrimSpace(set.CanonicalBaseURL); v != "" {
base = v
}
}
}
if base == "" {
base = strings.TrimSpace(s.config.FrontendBaseURL)
}
if base == "" {
return u
}
base = strings.TrimSuffix(base, "/")
if strings.HasPrefix(u, "/") {
return base + u
}
return base + "/" + u
}
// SendAdminWelcome sends a welcome email to the new admin using the fixed
// system SMTP account (system@tdvorak.dev), so it works even before club SMTP
// is configured. The content uses templates/emails/admin_welcome.html rendered
@@ -261,15 +296,7 @@ func (s *emailService) SendAdminWelcome(to string) error {
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"
}
clubLogo := s.normalizeLogoURL(strings.TrimSpace(set.ClubLogoURL), set.ClubID)
primaryColor := strings.TrimSpace(set.PrimaryColor)
if primaryColor == "" {
primaryColor = "#1e3a8a"
@@ -444,7 +471,7 @@ func (s *emailService) SendPasswordReset(to string, resetLink string, useOverrid
if clubName == "" {
clubName = "Fotbal Club"
}
clubLogo := strings.TrimSpace(set.ClubLogoURL)
clubLogo := s.normalizeLogoURL(strings.TrimSpace(set.ClubLogoURL), set.ClubID)
if clubLogo == "" {
if clubID := strings.TrimSpace(set.ClubID); clubID != "" {
// Use PNG format for better email client compatibility (SVG not widely supported)
@@ -603,7 +630,7 @@ func (s *emailService) SendEmail(data *EmailData) error {
if clubName == "" {
clubName = "Fotbal Club"
}
clubLogo := strings.TrimSpace(set.ClubLogoURL)
clubLogo := s.normalizeLogoURL(strings.TrimSpace(set.ClubLogoURL), set.ClubID)
if clubLogo == "" {
if clubID := strings.TrimSpace(set.ClubID); clubID != "" {
// Use PNG format for better email client compatibility (SVG not widely supported)
@@ -816,12 +843,12 @@ func (s *emailService) SendEmail(data *EmailData) error {
// 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
Name string
Email string
Subject string
Message string
IPAddress string
UserAgent string
}
func (s *emailService) SendContactForm(data *ContactFormData) error {
@@ -866,8 +893,12 @@ func (s *emailService) SendContactForm(data *ContactFormData) error {
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 }
if v == "" {
continue
}
if _, ok := uniq[v]; ok {
continue
}
uniq[v] = struct{}{}
dedup = append(dedup, e)
}
@@ -912,171 +943,179 @@ func (s *emailService) SendContactForm(data *ContactFormData) error {
// 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, "/")
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
}
// 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)
// 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
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)
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)
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)
}