mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 02:32:57 +00:00
1178 lines
36 KiB
Go
1178 lines
36 KiB
Go
package email
|
||
|
||
import (
|
||
"bytes"
|
||
"crypto/rand"
|
||
"crypto/tls"
|
||
"encoding/hex"
|
||
"fmt"
|
||
"html/template"
|
||
"net/url"
|
||
"os"
|
||
"path/filepath"
|
||
"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
|
||
}
|
||
|
||
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("/api/v1/email/click", url.Values{"m": {fmt.Sprintf("%d", elog.ID)}, "t": {trackTok}, "u": {unsubscribePath}}),
|
||
ManageURL: makeAbs("/api/v1/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("/api/v1/email/open.gif", url.Values{"m": {fmt.Sprintf("%d", elog.ID)}, "t": {trackTok}}),
|
||
WebsiteURL: func() string {
|
||
if siteURL == "" {
|
||
return ""
|
||
}
|
||
return makeAbs("/api/v1/email/click", url.Values{"m": {fmt.Sprintf("%d", elog.ID)}, "t": {trackTok}, "u": {siteURL}})
|
||
}(),
|
||
ClubURL: func() string {
|
||
if clubURL == "" {
|
||
return ""
|
||
}
|
||
return makeAbs("/api/v1/email/click", url.Values{"m": {fmt.Sprintf("%d", elog.ID)}, "t": {trackTok}, "u": {clubURL}})
|
||
}(),
|
||
ContactEmail: contactEmail,
|
||
ContactURL: func() string {
|
||
if contactURL == "" {
|
||
return ""
|
||
}
|
||
return makeAbs("/api/v1/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("/api/v1/email/click", url.Values{"m": {fmt.Sprintf("%d", elog.ID)}, "t": {trackTok}, "u": {v}})
|
||
}
|
||
if v := getStringField(set, "InstagramURL"); v != "" {
|
||
personalTemplateData.InstagramURL = makeAbs("/api/v1/email/click", url.Values{"m": {fmt.Sprintf("%d", elog.ID)}, "t": {trackTok}, "u": {v}})
|
||
}
|
||
if v := getStringField(set, "YouTubeURL"); v != "" {
|
||
personalTemplateData.YouTubeURL = makeAbs("/api/v1/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("/api/v1/email/click", url.Values{"m": {fmt.Sprintf("%d", elog.ID)}, "t": {trackTok}, "u": {v}})
|
||
}
|
||
}
|
||
if v := getStringField(set, "TwitterURL"); v != "" {
|
||
personalTemplateData.TwitterURL = makeAbs("/api/v1/email/click", url.Values{"m": {fmt.Sprintf("%d", elog.ID)}, "t": {trackTok}, "u": {v}})
|
||
}
|
||
|
||
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
|
||
}
|