mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 02:32:57 +00:00
dev day #92
This commit is contained in:
+226
-187
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user