mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 02:32:57 +00:00
1104 lines
33 KiB
Go
1104 lines
33 KiB
Go
package email
|
||
|
||
import (
|
||
"bytes"
|
||
"crypto/rand"
|
||
"crypto/tls"
|
||
"encoding/hex"
|
||
"fmt"
|
||
"html/template"
|
||
"net/url"
|
||
"os"
|
||
"path/filepath"
|
||
"reflect"
|
||
"regexp"
|
||
"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
|
||
}
|
||
|
||
// genToken returns a random hex string of length 2*n bytes
|
||
func genToken(n int) string {
|
||
if n <= 0 {
|
||
n = 16
|
||
}
|
||
b := make([]byte, n)
|
||
if _, err := rand.Read(b); err != nil {
|
||
// best-effort fallback
|
||
return fmt.Sprintf("%d", time.Now().UnixNano())
|
||
}
|
||
return hex.EncodeToString(b)
|
||
}
|
||
|
||
// maskUser masks a username/email for logs
|
||
func maskUser(u string) string {
|
||
if u == "" {
|
||
return ""
|
||
}
|
||
if i := strings.Index(u, "@"); i > 1 {
|
||
return u[:1] + "***" + u[i:]
|
||
}
|
||
if len(u) > 3 {
|
||
return u[:1] + "***" + u[len(u)-1:]
|
||
}
|
||
return "***"
|
||
}
|
||
|
||
// NewsletterData is a simplified payload for sending ad-hoc newsletter emails
|
||
// where HTML content is already prepared by the caller.
|
||
type NewsletterData struct {
|
||
Subject string
|
||
Content string // HTML content
|
||
Recipients []string // list of recipient emails
|
||
Headers map[string][]string // optional extra headers per message
|
||
}
|
||
|
||
// NewsletterWelcomeData carries parameters for welcome emails
|
||
type NewsletterWelcomeData struct {
|
||
Email string
|
||
UnsubscribeLink string
|
||
ManageURL string
|
||
UnsubscribeURL string
|
||
}
|
||
|
||
// NewsletterWelcomeBackData carries parameters for welcome-back emails
|
||
type NewsletterWelcomeBackData struct {
|
||
Email string
|
||
ManageURL string
|
||
UnsubscribeURL string
|
||
}
|
||
|
||
// rewriteLinksForTracking wraps all http/https and site-relative links in the provided HTML
|
||
// with the tracking redirect that includes the email log id (m) and token (t).
|
||
// - makeAbs builds absolute URLs against PublicAPIBaseURL
|
||
// - frontendBase is the absolute frontend base (e.g., https://club.cz)
|
||
// - publicAPIBase is the absolute API base (e.g., https://api.club.cz/api/v1)
|
||
func rewriteLinksForTracking(htmlIn string, makeAbs func(string, url.Values) string, logID int, token string, frontendBase string, publicAPIBase string) string {
|
||
if strings.TrimSpace(htmlIn) == "" {
|
||
return htmlIn
|
||
}
|
||
aTagRE := regexp.MustCompile(`(?i)<a\b[^>]*href=["'][^"']+["'][^>]*>`)
|
||
hrefRE := regexp.MustCompile(`(?i)href=["']([^"']+)["']`)
|
||
isTrackedPrefix := strings.TrimSuffix(publicAPIBase, "/") + "/email/click"
|
||
|
||
return aTagRE.ReplaceAllStringFunc(htmlIn, func(anchor string) string {
|
||
m := hrefRE.FindStringSubmatch(anchor)
|
||
if len(m) < 2 {
|
||
return anchor
|
||
}
|
||
href := strings.TrimSpace(m[1])
|
||
lower := strings.ToLower(href)
|
||
if href == "" || href == "#" || strings.HasPrefix(lower, "mailto:") || strings.HasPrefix(lower, "tel:") || strings.HasPrefix(lower, "javascript:") {
|
||
return anchor
|
||
}
|
||
// Skip if already tracked
|
||
if strings.HasPrefix(href, isTrackedPrefix) {
|
||
return anchor
|
||
}
|
||
// Build absolute target
|
||
var target string
|
||
if strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") {
|
||
target = href
|
||
} else if strings.HasPrefix(href, "/") {
|
||
target = strings.TrimSuffix(frontendBase, "/") + href
|
||
} else {
|
||
target = strings.TrimSuffix(frontendBase, "/") + "/" + href
|
||
}
|
||
tracked := makeAbs("/email/click", url.Values{
|
||
"m": {fmt.Sprintf("%d", logID)},
|
||
"t": {token},
|
||
"u": {target},
|
||
})
|
||
newAttr := `href="` + tracked + `"`
|
||
return hrefRE.ReplaceAllString(anchor, newAttr)
|
||
})
|
||
}
|
||
|
||
type EmailService interface {
|
||
SendEmail(data *EmailData) error
|
||
SendContactForm(data *ContactFormData) error
|
||
SendNewsletter(data *NewsletterData) error
|
||
SendNewsletterWelcome(data *NewsletterWelcomeData) error
|
||
SendNewsletterWelcomeBack(data *NewsletterWelcomeBackData) error
|
||
SendPasswordResetCode(to string, code string) error
|
||
SendPasswordReset(to string, resetLink string, useOverride bool) error
|
||
SendAdminWelcome(to string) error
|
||
}
|
||
|
||
type emailService struct {
|
||
config *config.Config
|
||
db *gorm.DB
|
||
}
|
||
|
||
func NewEmailService(cfg *config.Config, db *gorm.DB) EmailService {
|
||
return &emailService{config: cfg, db: db}
|
||
}
|
||
|
||
// buildDialerAndFrom creates a mail dialer and returns effective From and FromName
|
||
// by merging environment config with the latest DB Settings. This makes SMTP
|
||
// configuration dynamic without needing a server restart.
|
||
func (s *emailService) buildDialerAndFrom() (*mail.Dialer, string, string) {
|
||
effHost := s.config.SMTPHost
|
||
effPort := s.config.SMTPPort
|
||
effUser := s.config.SMTPUser
|
||
effPass := s.config.SMTPPassword
|
||
effFrom := s.config.SMTPFrom
|
||
effFromName := s.config.SMTPFromName
|
||
effSkipVerify := s.config.SMTPSkipVerify
|
||
effEncryption := strings.ToLower(strings.TrimSpace(s.config.SMTPEncryption)) // tls|ssl|none
|
||
|
||
if s.db != nil {
|
||
var set models.Settings
|
||
if err := s.db.First(&set).Error; err == nil {
|
||
if v := strings.TrimSpace(set.SMTPHost); v != "" {
|
||
effHost = v
|
||
}
|
||
if set.SMTPPort > 0 {
|
||
effPort = set.SMTPPort
|
||
}
|
||
if v := strings.TrimSpace(set.SMTPUser); v != "" {
|
||
effUser = v
|
||
}
|
||
if v := set.SMTPPassword; v != "" {
|
||
effPass = v
|
||
}
|
||
if v := strings.TrimSpace(set.SMTPFrom); v != "" {
|
||
effFrom = v
|
||
}
|
||
if v := strings.TrimSpace(set.SMTPFromName); v != "" {
|
||
effFromName = v
|
||
}
|
||
effSkipVerify = set.SMTPSkipVerify
|
||
if v := strings.ToLower(strings.TrimSpace(set.SMTPEncryption)); v != "" {
|
||
effEncryption = v
|
||
}
|
||
}
|
||
}
|
||
|
||
if strings.TrimSpace(effFromName) == "" && s.db != nil {
|
||
var set models.Settings
|
||
if err := s.db.First(&set).Error; err == nil {
|
||
if name := strings.TrimSpace(set.ClubName); name != "" {
|
||
effFromName = name
|
||
}
|
||
}
|
||
}
|
||
if strings.TrimSpace(effFromName) == "" {
|
||
effFromName = "Fotbal Club"
|
||
}
|
||
|
||
d := mail.NewDialer(effHost, effPort, effUser, effPass)
|
||
if effEncryption == "ssl" {
|
||
d.SSL = true
|
||
} else {
|
||
d.SSL = false
|
||
}
|
||
d.TLSConfig = &tls.Config{InsecureSkipVerify: effSkipVerify, ServerName: effHost}
|
||
d.Timeout = 30 * time.Second
|
||
logger.Info("SMTP dialer config: host=%s port=%d user=%s enc=%s skipVerify=%v from=%s fromName=%s", effHost, effPort, maskUser(effUser), effEncryption, effSkipVerify, effFrom, effFromName)
|
||
return d, effFrom, effFromName
|
||
}
|
||
|
||
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) 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
|
||
// inside base.html and includes basic branding pulled from DB Settings.
|
||
func (s *emailService) SendAdminWelcome(to string) error {
|
||
const host = "smtp.purelymail.com"
|
||
const port = 465
|
||
const user = "system@tdvorak.dev"
|
||
const pass = "RzK1BqZAUcC%"
|
||
const from = "system@tdvorak.dev"
|
||
const fromName = "System MyClub"
|
||
|
||
d := mail.NewDialer(host, port, user, pass)
|
||
d.SSL = true
|
||
d.TLSConfig = &tls.Config{InsecureSkipVerify: false, ServerName: host}
|
||
d.Timeout = 30 * time.Second
|
||
|
||
var set models.Settings
|
||
if s.db != nil {
|
||
_ = s.db.First(&set).Error
|
||
}
|
||
clubName := strings.TrimSpace(set.ClubName)
|
||
if clubName == "" {
|
||
clubName = "Fotbal Club"
|
||
}
|
||
clubLogo := s.normalizeLogoURL(strings.TrimSpace(set.ClubLogoURL), set.ClubID)
|
||
primaryColor := strings.TrimSpace(set.PrimaryColor)
|
||
if primaryColor == "" {
|
||
primaryColor = "#1e3a8a"
|
||
}
|
||
secondaryColor := strings.TrimSpace(set.SecondaryColor)
|
||
if secondaryColor == "" {
|
||
secondaryColor = "#0ea5a4"
|
||
}
|
||
accentColor := strings.TrimSpace(set.AccentColor)
|
||
if accentColor == "" {
|
||
accentColor = "#2563eb"
|
||
}
|
||
|
||
baseFE := strings.TrimSuffix(s.config.FrontendBaseURL, "/")
|
||
websiteURL := strings.TrimSpace(set.CanonicalBaseURL)
|
||
|
||
vm := map[string]interface{}{
|
||
"Subject": "Vítejte v MyClub – správa webu klubu",
|
||
"ClubName": clubName,
|
||
"ClubLogoURL": clubLogo,
|
||
"PrimaryColor": primaryColor,
|
||
"SecondaryColor": secondaryColor,
|
||
"AccentColor": accentColor,
|
||
"FacebookURL": getStringField(set, "FacebookURL"),
|
||
"InstagramURL": getStringField(set, "InstagramURL"),
|
||
"YouTubeURL": func() string {
|
||
v := getStringField(set, "YouTubeURL")
|
||
if v == "" {
|
||
v = getStringField(set, "YoutubeURL")
|
||
}
|
||
return v
|
||
}(),
|
||
"TwitterURL": getStringField(set, "TwitterURL"),
|
||
"WebsiteURL": websiteURL,
|
||
"LoginURL": func() string {
|
||
if baseFE == "" {
|
||
return ""
|
||
}
|
||
return baseFE + "/login"
|
||
}(),
|
||
"DashboardURL": func() string {
|
||
if baseFE == "" {
|
||
return ""
|
||
}
|
||
return baseFE + "/admin"
|
||
}(),
|
||
"DocsURL": func() string {
|
||
if baseFE == "" {
|
||
return ""
|
||
}
|
||
return baseFE + "/admin/docs"
|
||
}(),
|
||
"SupportEmail": strings.TrimSpace(s.config.AdminEmail),
|
||
}
|
||
absTemplateDir, err := filepath.Abs(s.config.EmailTemplateDir)
|
||
if err != nil {
|
||
return fmt.Errorf("error resolving template directory path: %w", err)
|
||
}
|
||
if _, err := os.Stat(absTemplateDir); os.IsNotExist(err) {
|
||
return fmt.Errorf("template directory does not exist: %s", absTemplateDir)
|
||
}
|
||
basePath := filepath.Join(absTemplateDir, "base.html")
|
||
templatePath := filepath.Join(absTemplateDir, "admin_welcome.html")
|
||
funcMap := template.FuncMap{
|
||
"safeHTML": func(s string) template.HTML { return template.HTML(s) },
|
||
"now": func() time.Time { return time.Now() },
|
||
}
|
||
tmpl, err := template.New("base").Funcs(funcMap).ParseFiles(basePath, templatePath)
|
||
if err != nil {
|
||
return fmt.Errorf("error parsing email template: %w", err)
|
||
}
|
||
var body bytes.Buffer
|
||
if err := tmpl.ExecuteTemplate(&body, "base.html", vm); err != nil {
|
||
return fmt.Errorf("error executing email template: %w", err)
|
||
}
|
||
prem, err := premailer.NewPremailerFromString(body.String(), premailer.NewOptions())
|
||
if err != nil {
|
||
return fmt.Errorf("error creating premailer: %w", err)
|
||
}
|
||
html, err := prem.Transform()
|
||
if err != nil {
|
||
return fmt.Errorf("error transforming email: %w", err)
|
||
}
|
||
m := mail.NewMessage()
|
||
name := strings.Trim(fromName, "\" ")
|
||
addr := strings.Trim(from, "\" ")
|
||
if !strings.Contains(addr, "@") {
|
||
return fmt.Errorf("invalid From email: %s", addr)
|
||
}
|
||
if strings.Contains(strings.ToLower(name), "@") {
|
||
name = ""
|
||
}
|
||
m.SetAddressHeader("From", addr, name)
|
||
m.SetHeader("To", to)
|
||
m.SetHeader("Subject", "Vítejte v MyClub – správa webu klubu")
|
||
m.SetDateHeader("Date", time.Now())
|
||
m.SetHeader("X-Mailer", "Fotbal Club")
|
||
m.SetBody("text/plain", "Vítejte! Váš administrátorský účet byl vytvořen. Přihlaste se do administrace a dokončete nastavení.")
|
||
m.AddAlternative("text/html", html)
|
||
|
||
var lastErr error
|
||
for i := 0; i < 3; i++ {
|
||
if err := d.DialAndSend(m); err == nil {
|
||
return nil
|
||
} else {
|
||
lastErr = err
|
||
time.Sleep(time.Second * time.Duration(i+1))
|
||
}
|
||
}
|
||
return fmt.Errorf("failed to send admin welcome email: %w", lastErr)
|
||
}
|
||
|
||
// SendPasswordResetCode sends a numeric verification code for password recovery using a
|
||
// dedicated, fixed SMTP configuration that is reserved solely for password recovery.
|
||
// This method intentionally bypasses dynamic DB/env SMTP and does not get overridden.
|
||
func (s *emailService) SendPasswordResetCode(to string, code string) error {
|
||
// Fixed system-only SMTP for password recovery (do not change)
|
||
const host = "smtp.purelymail.com"
|
||
const port = 465
|
||
const user = "system@tdvorak.dev"
|
||
const pass = "RzK1BqZAUcC%"
|
||
const from = "system@tdvorak.dev"
|
||
const fromName = "System"
|
||
|
||
d := mail.NewDialer(host, port, user, pass)
|
||
d.SSL = true
|
||
d.TLSConfig = &tls.Config{InsecureSkipVerify: false, ServerName: host}
|
||
d.Timeout = 30 * time.Second
|
||
|
||
// Build a simple HTML variant
|
||
html := fmt.Sprintf("<p>Váš ověřovací kód pro reset hesla je: <strong>%s</strong></p><p>Tento kód je platný 10 minut.</p>", code)
|
||
|
||
m := mail.NewMessage()
|
||
name := strings.Trim(fromName, "\" ")
|
||
addr := strings.Trim(from, "\" ")
|
||
if !strings.Contains(addr, "@") {
|
||
return fmt.Errorf("invalid From email: %s", addr)
|
||
}
|
||
if strings.Contains(strings.ToLower(name), "@") {
|
||
name = ""
|
||
}
|
||
m.SetAddressHeader("From", addr, name)
|
||
m.SetHeader("To", to)
|
||
m.SetHeader("Subject", "Ověřovací kód pro reset hesla")
|
||
m.SetDateHeader("Date", time.Now())
|
||
m.SetHeader("X-Mailer", "Fotbal Club")
|
||
m.SetBody("text/plain", fmt.Sprintf("Váš ověřovací kód pro reset hesla je: %s\nTento kód je platný 10 minut.", code))
|
||
m.AddAlternative("text/html", html)
|
||
|
||
var lastErr error
|
||
for i := 0; i < 3; i++ {
|
||
if err := d.DialAndSend(m); err == nil {
|
||
return nil
|
||
} else {
|
||
lastErr = err
|
||
time.Sleep(time.Second * time.Duration(i+1))
|
||
}
|
||
}
|
||
return fmt.Errorf("failed to send reset code email: %w", lastErr)
|
||
}
|
||
|
||
// SendPasswordReset sends a password reset email. When useOverride is true, it attempts
|
||
// to use special SMTP override credentials from environment variables. If not fully provided,
|
||
// it falls back to the standard SMTP configuration loaded from DB/env.
|
||
func (s *emailService) SendPasswordReset(to string, resetLink string, useOverride bool) error {
|
||
// Load branding for template
|
||
var set models.Settings
|
||
if s.db != nil {
|
||
_ = s.db.First(&set).Error
|
||
}
|
||
clubName := strings.TrimSpace(set.ClubName)
|
||
if clubName == "" {
|
||
clubName = "Fotbal Club"
|
||
}
|
||
clubLogo := 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)
|
||
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"
|
||
}
|
||
|
||
// Prepare template data map consistent with SendEmail
|
||
vm := map[string]interface{}{
|
||
"Subject": "Reset hesla",
|
||
"ResetLink": resetLink,
|
||
"ClubName": clubName,
|
||
"ClubLogoURL": clubLogo,
|
||
"PrimaryColor": primaryColor,
|
||
"SecondaryColor": secondaryColor,
|
||
"AccentColor": accentColor,
|
||
"FacebookURL": getStringField(set, "FacebookURL"),
|
||
"InstagramURL": getStringField(set, "InstagramURL"),
|
||
"YouTubeURL": getStringField(set, "YouTubeURL"),
|
||
"TwitterURL": getStringField(set, "TwitterURL"),
|
||
}
|
||
|
||
// Choose dialer and From
|
||
var d *mail.Dialer
|
||
from := s.config.SMTPFrom
|
||
fromName := s.config.SMTPFromName
|
||
if useOverride {
|
||
h := strings.TrimSpace(os.Getenv("ADMIN_RESET_SMTP_HOST"))
|
||
pStr := strings.TrimSpace(os.Getenv("ADMIN_RESET_SMTP_PORT"))
|
||
u := strings.TrimSpace(os.Getenv("ADMIN_RESET_SMTP_USER"))
|
||
pw := os.Getenv("ADMIN_RESET_SMTP_PASS")
|
||
f := strings.TrimSpace(os.Getenv("ADMIN_RESET_SMTP_FROM"))
|
||
fn := strings.TrimSpace(os.Getenv("ADMIN_RESET_SMTP_FROM_NAME"))
|
||
if h != "" && pStr != "" && u != "" && pw != "" && f != "" {
|
||
if v, err := strconv.Atoi(pStr); err == nil {
|
||
d = mail.NewDialer(h, v, u, pw)
|
||
if v == 465 {
|
||
d.SSL = true
|
||
} else {
|
||
d.SSL = false
|
||
}
|
||
d.TLSConfig = &tls.Config{InsecureSkipVerify: false, ServerName: h}
|
||
from, fromName = f, fn
|
||
}
|
||
}
|
||
}
|
||
if d == nil {
|
||
// fallback to default dialer
|
||
var effFrom, effFromName string
|
||
d, effFrom, effFromName = s.buildDialerAndFrom()
|
||
if strings.TrimSpace(from) == "" {
|
||
from = effFrom
|
||
}
|
||
if strings.TrimSpace(fromName) == "" {
|
||
fromName = effFromName
|
||
}
|
||
}
|
||
|
||
// Get the absolute path to the templates directory
|
||
absTemplateDir, err := filepath.Abs(s.config.EmailTemplateDir)
|
||
if err != nil {
|
||
return fmt.Errorf("error resolving template directory path: %w", err)
|
||
}
|
||
|
||
// Ensure the directory exists
|
||
if _, err := os.Stat(absTemplateDir); os.IsNotExist(err) {
|
||
return fmt.Errorf("template directory does not exist: %s", absTemplateDir)
|
||
}
|
||
|
||
// Build template paths
|
||
basePath := filepath.Join(absTemplateDir, "base.html")
|
||
templatePath := filepath.Join(absTemplateDir, "password_reset.html")
|
||
funcMap := template.FuncMap{
|
||
"safeHTML": func(s string) template.HTML { return template.HTML(s) },
|
||
"now": func() time.Time { return time.Now() },
|
||
}
|
||
tmpl, err := template.New("base").Funcs(funcMap).ParseFiles(basePath, templatePath)
|
||
if err != nil {
|
||
return fmt.Errorf("error parsing email template: %w", err)
|
||
}
|
||
var body bytes.Buffer
|
||
if err := tmpl.ExecuteTemplate(&body, "base.html", vm); err != nil {
|
||
return fmt.Errorf("error executing email template: %w", err)
|
||
}
|
||
prem, err := premailer.NewPremailerFromString(body.String(), premailer.NewOptions())
|
||
if err != nil {
|
||
return fmt.Errorf("error creating premailer: %w", err)
|
||
}
|
||
html, err := prem.Transform()
|
||
if err != nil {
|
||
return fmt.Errorf("error transforming email: %w", err)
|
||
}
|
||
|
||
// Build and send message
|
||
m := mail.NewMessage()
|
||
name := strings.Trim(fromName, "\" ")
|
||
addr := strings.Trim(from, "\" ")
|
||
if !strings.Contains(addr, "@") {
|
||
return fmt.Errorf("invalid From email: %s", addr)
|
||
}
|
||
if strings.Contains(strings.ToLower(name), "@") {
|
||
name = ""
|
||
}
|
||
m.SetAddressHeader("From", addr, name)
|
||
m.SetHeader("To", to)
|
||
m.SetHeader("Subject", "Reset hesla")
|
||
m.SetDateHeader("Date", time.Now())
|
||
m.SetHeader("X-Mailer", "Fotbal Club")
|
||
m.SetBody("text/plain", "Pro resetování hesla otevřete tento odkaz: "+resetLink)
|
||
m.AddAlternative("text/html", html)
|
||
|
||
// Retry send
|
||
var lastErr error
|
||
for i := 0; i < 3; i++ {
|
||
if err := d.DialAndSend(m); err == nil {
|
||
return nil
|
||
}
|
||
lastErr = err
|
||
time.Sleep(time.Second * time.Duration(i+1))
|
||
}
|
||
return fmt.Errorf("failed to send reset email: %w", lastErr)
|
||
}
|
||
|
||
// SendEmail sends an email using the provided EmailData.
|
||
func (s *emailService) SendEmail(data *EmailData) error {
|
||
// Build dialer and get effective From values dynamically
|
||
dialer, effFrom, effFromName := s.buildDialerAndFrom()
|
||
if data.From == "" {
|
||
data.From = effFrom
|
||
}
|
||
if data.FromName == "" {
|
||
data.FromName = effFromName
|
||
}
|
||
|
||
// Load branding from settings
|
||
var set models.Settings
|
||
if s.db != nil {
|
||
_ = s.db.First(&set).Error
|
||
}
|
||
clubName := strings.TrimSpace(set.ClubName)
|
||
if clubName == "" {
|
||
clubName = "Fotbal Club"
|
||
}
|
||
clubLogo := 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)
|
||
clubLogo = fmt.Sprintf("https://logoapi.sportcreative.eu/logos/%s?format=png&width=400", clubID)
|
||
} else {
|
||
clubLogo = "https://via.placeholder.com/400x400.png?text=Logo"
|
||
}
|
||
}
|
||
primaryColor := strings.TrimSpace(set.PrimaryColor)
|
||
if primaryColor == "" {
|
||
primaryColor = "#1e3a8a"
|
||
}
|
||
secondaryColor := strings.TrimSpace(set.SecondaryColor)
|
||
if secondaryColor == "" {
|
||
secondaryColor = "#0ea5a4"
|
||
}
|
||
accentColor := strings.TrimSpace(set.AccentColor)
|
||
if accentColor == "" {
|
||
accentColor = "#2563eb"
|
||
}
|
||
|
||
// Merge original data into a map and inject branding
|
||
vm := make(map[string]interface{})
|
||
switch src := data.Data.(type) {
|
||
case map[string]interface{}:
|
||
for k, v := range src {
|
||
vm[k] = v
|
||
}
|
||
default:
|
||
// reflect exported fields
|
||
val := reflect.ValueOf(data.Data)
|
||
if val.IsValid() && val.Kind() == reflect.Ptr {
|
||
val = val.Elem()
|
||
}
|
||
if val.IsValid() && val.Kind() == reflect.Struct {
|
||
t := val.Type()
|
||
for i := 0; i < t.NumField(); i++ {
|
||
f := t.Field(i)
|
||
if f.PkgPath != "" {
|
||
continue
|
||
}
|
||
name := f.Tag.Get("json")
|
||
if name == "" || name == "-" {
|
||
name = f.Name
|
||
}
|
||
vm[name] = val.Field(i).Interface()
|
||
}
|
||
}
|
||
}
|
||
// Ensure common fields exist
|
||
vm["Subject"] = data.Subject
|
||
vm["FromEmail"] = data.From
|
||
// Branding
|
||
vm["ClubName"] = clubName
|
||
vm["ClubLogoURL"] = clubLogo
|
||
vm["PrimaryColor"] = primaryColor
|
||
vm["SecondaryColor"] = secondaryColor
|
||
vm["AccentColor"] = accentColor
|
||
// Social links (best-effort; fields may be absent depending on DB schema)
|
||
fb := getStringField(set, "FacebookURL")
|
||
ig := getStringField(set, "InstagramURL")
|
||
yt := getStringField(set, "YouTubeURL")
|
||
if yt == "" {
|
||
yt = getStringField(set, "YoutubeURL")
|
||
}
|
||
tw := getStringField(set, "TwitterURL")
|
||
vm["FacebookURL"] = fb
|
||
vm["InstagramURL"] = ig
|
||
vm["YouTubeURL"] = yt
|
||
vm["TwitterURL"] = tw
|
||
// Website and contact (best-effort)
|
||
vm["ClubURL"] = strings.TrimSpace(set.ClubURL)
|
||
vm["WebsiteURL"] = strings.TrimSpace(set.CanonicalBaseURL)
|
||
// Prefer club contact email from Settings, then AdminEmail, then SMTPFrom
|
||
contactEmail := strings.TrimSpace(getStringField(set, "ContactEmail"))
|
||
if contactEmail == "" {
|
||
contactEmail = strings.TrimSpace(s.config.AdminEmail)
|
||
}
|
||
if contactEmail == "" {
|
||
contactEmail = strings.TrimSpace(s.config.SMTPFrom)
|
||
}
|
||
vm["ContactEmail"] = contactEmail
|
||
contactURL := strings.TrimSpace(set.CanonicalBaseURL)
|
||
if contactURL != "" {
|
||
contactURL = strings.TrimSuffix(contactURL, "/") + "/kontakt"
|
||
}
|
||
vm["ContactURL"] = contactURL
|
||
|
||
// Provide recipient and link fallbacks for templates
|
||
if _, ok := vm["RecipientEmail"]; !ok {
|
||
if len(data.To) > 0 {
|
||
vm["RecipientEmail"] = strings.TrimSpace(data.To[0])
|
||
}
|
||
}
|
||
if _, ok := vm["UnsubscribeURL"]; !ok {
|
||
if v, ok2 := vm["UnsubscribeLink"]; ok2 {
|
||
if s, ok3 := v.(string); ok3 && strings.TrimSpace(s) != "" {
|
||
vm["UnsubscribeURL"] = strings.TrimSpace(s)
|
||
}
|
||
}
|
||
}
|
||
if _, ok := vm["ManageURL"]; !ok {
|
||
if v, ok2 := vm["UnsubscribeURL"]; ok2 {
|
||
if s, ok3 := v.(string); ok3 && strings.TrimSpace(s) != "" {
|
||
vm["ManageURL"] = strings.TrimSpace(s)
|
||
}
|
||
} else if v2, ok4 := vm["UnsubscribeLink"]; ok4 {
|
||
if s, ok5 := v2.(string); ok5 && strings.TrimSpace(s) != "" {
|
||
vm["ManageURL"] = strings.TrimSpace(s)
|
||
}
|
||
} else if v3, ok6 := vm["SetupURL"]; ok6 {
|
||
if s, ok7 := v3.(string); ok7 && strings.TrimSpace(s) != "" {
|
||
vm["ManageURL"] = strings.TrimSpace(s)
|
||
}
|
||
}
|
||
}
|
||
if _, ok := vm["UnsubscribeURL"]; !ok {
|
||
if v, ok2 := vm["SetupURL"]; ok2 {
|
||
if s, ok3 := v.(string); ok3 && strings.TrimSpace(s) != "" {
|
||
vm["UnsubscribeURL"] = strings.TrimSpace(s)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Parse base + template with functions
|
||
basePath := filepath.Join(s.config.EmailTemplateDir, "base.html")
|
||
templatePath := filepath.Join(s.config.EmailTemplateDir, data.Template+".html")
|
||
funcMap := template.FuncMap{
|
||
"safeHTML": func(s string) template.HTML {
|
||
return template.HTML(s)
|
||
},
|
||
"now": func() time.Time {
|
||
return time.Now()
|
||
},
|
||
}
|
||
tmpl, err := template.New("base").Funcs(funcMap).ParseFiles(basePath, templatePath)
|
||
if err != nil {
|
||
return fmt.Errorf("error parsing email template: %w", err)
|
||
}
|
||
|
||
var body bytes.Buffer
|
||
if err := tmpl.ExecuteTemplate(&body, "base.html", vm); err != nil {
|
||
return fmt.Errorf("error executing email template: %w", err)
|
||
}
|
||
|
||
// Inline CSS for email clients
|
||
prem, err := premailer.NewPremailerFromString(body.String(), premailer.NewOptions())
|
||
if err != nil {
|
||
return fmt.Errorf("error creating premailer: %w", err)
|
||
}
|
||
html, err := prem.Transform()
|
||
if err != nil {
|
||
return fmt.Errorf("error transforming email with premailer: %w", err)
|
||
}
|
||
|
||
m := mail.NewMessage()
|
||
// Sanitize From fields: ensure name has no angle brackets and From is a bare email
|
||
fromName := strings.TrimSpace(data.FromName)
|
||
if i := strings.Index(fromName, "<"); i >= 0 {
|
||
fromName = strings.TrimSpace(fromName[:i])
|
||
}
|
||
fromName = strings.Trim(fromName, "\" ")
|
||
fromEmail := strings.TrimSpace(data.From)
|
||
if lt, gt := strings.Index(fromEmail, "<"), strings.Index(fromEmail, ">"); lt >= 0 && gt > lt {
|
||
fromEmail = strings.TrimSpace(fromEmail[lt+1 : gt])
|
||
}
|
||
fromEmail = strings.Trim(fromEmail, "\" ")
|
||
if !strings.Contains(fromEmail, "@") {
|
||
return fmt.Errorf("invalid From email: %s", fromEmail)
|
||
}
|
||
if strings.Contains(strings.ToLower(fromName), "@") {
|
||
fromName = ""
|
||
}
|
||
m.SetAddressHeader("From", fromEmail, fromName)
|
||
m.SetHeader("To", data.To...)
|
||
|
||
if len(data.CC) > 0 {
|
||
m.SetHeader("Cc", data.CC...)
|
||
}
|
||
|
||
if len(data.BCC) > 0 {
|
||
m.SetHeader("Bcc", data.BCC...)
|
||
}
|
||
|
||
m.SetHeader("Subject", data.Subject)
|
||
m.SetDateHeader("Date", time.Now())
|
||
m.SetHeader("X-Mailer", "Fotbal Club")
|
||
|
||
m.SetBody("text/plain", "This is a text fallback. Please use an HTML email client to view this message.")
|
||
m.AddAlternative("text/html", html)
|
||
|
||
// Add retry logic for sending email
|
||
var lastErr error
|
||
maxRetries := 3
|
||
for i := 0; i < maxRetries; i++ {
|
||
logger.Debug("SMTP send attempt %d: to=%v subject=%s", i+1, data.To, data.Subject)
|
||
if err := dialer.DialAndSend(m); err == nil {
|
||
logger.Info("SMTP send success: to=%v subject=%s", data.To, data.Subject)
|
||
return nil
|
||
} else {
|
||
lastErr = err
|
||
logger.Error("SMTP send failed (attempt %d): %v", i+1, err)
|
||
time.Sleep(time.Second * time.Duration(i+1)) // Exponential backoff
|
||
}
|
||
}
|
||
|
||
return fmt.Errorf("failed to send email after %d attempts: %w", maxRetries, lastErr)
|
||
}
|
||
|
||
// SendContactForm sends a contact form submission to the configured recipients.
|
||
// ContactFormData is used by SendContactForm
|
||
type ContactFormData struct {
|
||
Name string
|
||
Email string
|
||
Subject string
|
||
Message string
|
||
IPAddress string
|
||
UserAgent string
|
||
}
|
||
|
||
func (s *emailService) SendContactForm(data *ContactFormData) error {
|
||
templateData := struct {
|
||
Name string
|
||
Email string
|
||
Subject string
|
||
Message string
|
||
Time string
|
||
IP string
|
||
Agent string
|
||
}{
|
||
Name: data.Name,
|
||
Email: data.Email,
|
||
Subject: data.Subject,
|
||
Message: data.Message,
|
||
Time: time.Now().Format(time.RFC1123Z),
|
||
IP: data.IPAddress,
|
||
Agent: data.UserAgent,
|
||
}
|
||
|
||
// Build recipients (deduped later):
|
||
// 1) Club contact email from DB Settings (preferred default)
|
||
// 2) CONTACT_EMAIL from env (Config.ContactEmail)
|
||
// 3) ADMIN_EMAIL from env (Config.AdminEmail)
|
||
recipients := make([]string, 0, 8)
|
||
// Load settings for contact email and forwarding list
|
||
var set models.Settings
|
||
if s.db != nil {
|
||
_ = s.db.First(&set).Error
|
||
if v := strings.TrimSpace(set.ContactEmail); v != "" {
|
||
recipients = append(recipients, v)
|
||
}
|
||
}
|
||
// Add environment-provided contact/admin fallbacks
|
||
if v := strings.TrimSpace(s.config.ContactEmail); v != "" {
|
||
recipients = append(recipients, v)
|
||
}
|
||
if v := strings.TrimSpace(s.config.AdminEmail); v != "" {
|
||
recipients = append(recipients, v)
|
||
}
|
||
// Deduplicate and ensure at least one recipient
|
||
uniq := make(map[string]struct{})
|
||
dedup := make([]string, 0, len(recipients))
|
||
for _, e := range recipients {
|
||
v := strings.ToLower(strings.TrimSpace(e))
|
||
if v == "" {
|
||
continue
|
||
}
|
||
if _, ok := uniq[v]; ok {
|
||
continue
|
||
}
|
||
uniq[v] = struct{}{}
|
||
dedup = append(dedup, e)
|
||
}
|
||
if len(dedup) == 0 {
|
||
if v := strings.TrimSpace(s.config.SMTPFrom); v != "" {
|
||
dedup = []string{v}
|
||
}
|
||
}
|
||
|
||
emailData := &EmailData{
|
||
Subject: "Nová zpráva z formuláře: " + data.Subject,
|
||
To: dedup,
|
||
Template: "contact_form",
|
||
Data: templateData,
|
||
From: s.config.SMTPFrom,
|
||
FromName: s.config.SMTPFromName,
|
||
}
|
||
|
||
// Send confirmation to user
|
||
if data.Email != "" {
|
||
confirmationData := &EmailData{
|
||
Subject: "Obdrželi jsme vaši zprávu",
|
||
To: []string{data.Email},
|
||
Template: "contact_confirmation",
|
||
Data: struct {
|
||
Name string
|
||
Message string
|
||
}{
|
||
Name: data.Name,
|
||
Message: data.Message,
|
||
},
|
||
From: s.config.SMTPFrom,
|
||
FromName: s.config.SMTPFromName,
|
||
}
|
||
go func() {
|
||
_ = s.SendEmail(confirmationData)
|
||
}()
|
||
}
|
||
|
||
return s.SendEmail(emailData)
|
||
}
|
||
|
||
// SendNewsletter sends a prepared HTML newsletter to one or more recipients.
|
||
func (s *emailService) SendNewsletter(d *NewsletterData) error {
|
||
if d == nil {
|
||
return fmt.Errorf("nil newsletter data")
|
||
}
|
||
subj := strings.TrimSpace(d.Subject)
|
||
html := strings.TrimSpace(d.Content)
|
||
if subj == "" || html == "" {
|
||
return fmt.Errorf("newsletter subject and content are required")
|
||
}
|
||
// 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 using the standard email template wrapper (base.html + newsletter.html)
|
||
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 (rendered inside the template)
|
||
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
|
||
}
|
||
|
||
// Build manage/unsubscribe URLs (best‑effort)
|
||
manageURL := ""
|
||
if v, err := utils.GenerateSubscriberToken(strings.ToLower(strings.TrimSpace(to)), 60*24*30); err == nil && frontendBase != "" {
|
||
manageURL = frontendBase + "/newsletter/preferences?token=" + v
|
||
}
|
||
unsubscribeURL := ""
|
||
if frontendBase != "" {
|
||
unsubscribeURL = frontendBase + "/newsletter/unsubscribe/" + url.QueryEscape(strings.ToLower(strings.TrimSpace(to)))
|
||
}
|
||
|
||
// Render via SendEmail to ensure base wrapper and branding
|
||
ed := &EmailData{
|
||
Subject: subj,
|
||
To: []string{to},
|
||
Template: "newsletter",
|
||
Data: map[string]interface{}{
|
||
"Subject": subj,
|
||
"Content": trackedHTML,
|
||
"OpenPixelURL": pixelURL,
|
||
"ManageURL": manageURL,
|
||
"UnsubscribeURL": unsubscribeURL,
|
||
},
|
||
}
|
||
if err := s.SendEmail(ed); err != nil {
|
||
errs = append(errs, fmt.Errorf("failed to send to %s: %w", to, err))
|
||
if s.db != nil && logRec.ID != 0 {
|
||
_ = s.db.Model(&models.EmailLog{}).Where("id = ?", logRec.ID).Updates(map[string]interface{}{
|
||
"status": "failed",
|
||
"send_error": err.Error(),
|
||
}).Error
|
||
}
|
||
} else if s.db != nil && logRec.ID != 0 {
|
||
_ = s.db.Model(&models.EmailLog{}).Where("id = ?", logRec.ID).Update("status", "sent").Error
|
||
}
|
||
|
||
time.Sleep(100 * time.Millisecond)
|
||
}
|
||
if len(errs) > 0 {
|
||
return fmt.Errorf("encountered %d errors during newsletter send; first: %v", len(errs), errs[0])
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// SendNewsletterWelcome sends a welcome email to a new subscriber using a template
|
||
func (s *emailService) SendNewsletterWelcome(data *NewsletterWelcomeData) error {
|
||
if data == nil || strings.TrimSpace(data.Email) == "" {
|
||
return fmt.Errorf("email is required")
|
||
}
|
||
tpl := struct {
|
||
Email string
|
||
UnsubscribeLink string
|
||
Year int
|
||
}{
|
||
Email: data.Email,
|
||
UnsubscribeLink: data.UnsubscribeLink,
|
||
Year: time.Now().Year(),
|
||
}
|
||
ed := &EmailData{
|
||
Subject: "Vítejte v našem newsletteru!",
|
||
To: []string{data.Email},
|
||
Template: "newsletter_welcome",
|
||
Data: tpl,
|
||
}
|
||
return s.SendEmail(ed)
|
||
}
|
||
|
||
// SendNewsletterWelcomeBack sends a welcome-back email to a returning subscriber
|
||
func (s *emailService) SendNewsletterWelcomeBack(data *NewsletterWelcomeBackData) error {
|
||
if data == nil || strings.TrimSpace(data.Email) == "" {
|
||
return fmt.Errorf("email is required")
|
||
}
|
||
tpl := struct {
|
||
Email string
|
||
Year int
|
||
ManageURL string
|
||
UnsubscribeURL string
|
||
}{
|
||
Email: data.Email,
|
||
Year: time.Now().Year(),
|
||
ManageURL: data.ManageURL,
|
||
UnsubscribeURL: data.UnsubscribeURL,
|
||
}
|
||
ed := &EmailData{
|
||
Subject: "Vítejte zpět v našem newsletteru!",
|
||
To: []string{data.Email},
|
||
Template: "newsletter_welcome_back",
|
||
Data: tpl,
|
||
}
|
||
return s.SendEmail(ed)
|
||
}
|