15357fd3c0
yalarbacreate and moove into new directories for BegushiyBashkir and yalarbacreate and moove into new directories for BegushiyBashkir and yalarbacreate and moove into new directories for BegushiyBashkir and yalarbacreate and moove into new directories for BegushiyBashkir and yalarbacreate and moove into new directories for BegushiyBashkir and yalarbacreate and moove into new directories for BegushiyBashkir and yalarbacreate and moove into new directories for BegushiyBashkir and yalarbacreate and moove into new directories for BegushiyBashkir and yalarba
442 lines
14 KiB
Go
442 lines
14 KiB
Go
// pkg/email/email.go
|
|
package email
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"html/template"
|
|
"time"
|
|
|
|
"api_bb/internal/config"
|
|
"api_bb/pkg/logger"
|
|
|
|
"github.com/wneessen/go-mail"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// Service представляет сервис для отправки email
|
|
type Service struct {
|
|
client *mail.Client
|
|
config *config.Config
|
|
logger *zap.Logger
|
|
tmpl *template.Template
|
|
fromAddr string
|
|
isActive bool
|
|
}
|
|
|
|
// NewService создает новый экземпляр email сервиса
|
|
func NewService(cfg *config.Config) (*Service, error) {
|
|
log := logger.Get()
|
|
log.Info("Initializing email service")
|
|
|
|
// Проверяем обязательные параметры конфигурации
|
|
if err := validateConfig(cfg); err != nil {
|
|
log.Warn("Email service configuration is invalid, service will be disabled", zap.Error(err))
|
|
return &Service{
|
|
logger: log,
|
|
isActive: false,
|
|
}, nil
|
|
}
|
|
|
|
// Создаем SMTP клиент с правильными настройками
|
|
client, err := createSMTPClient(cfg)
|
|
if err != nil {
|
|
log.Warn("Failed to create SMTP client, email service will be disabled", zap.Error(err))
|
|
return &Service{
|
|
logger: log,
|
|
isActive: false,
|
|
}, nil
|
|
}
|
|
|
|
// Загружаем шаблоны писем
|
|
tmpl, err := loadTemplates()
|
|
if err != nil {
|
|
log.Warn("Failed to load email templates, email service will be disabled", zap.Error(err))
|
|
return &Service{
|
|
logger: log,
|
|
isActive: false,
|
|
}, nil
|
|
}
|
|
|
|
service := &Service{
|
|
client: client,
|
|
config: cfg,
|
|
logger: log,
|
|
tmpl: tmpl,
|
|
fromAddr: cfg.FromEmail,
|
|
isActive: true,
|
|
}
|
|
|
|
log.Info("Email service initialized successfully",
|
|
zap.String("host", cfg.SMTPHost),
|
|
zap.Int("port", cfg.SMTPPort),
|
|
zap.String("from", cfg.FromEmail))
|
|
|
|
return service, nil
|
|
}
|
|
|
|
// validateConfig проверяет корректность конфигурации email
|
|
func validateConfig(cfg *config.Config) error {
|
|
if cfg.SMTPHost == "" {
|
|
return fmt.Errorf("SMTP host is required")
|
|
}
|
|
|
|
if cfg.SMTPPort <= 0 || cfg.SMTPPort > 65535 {
|
|
return fmt.Errorf("invalid SMTP port: %d", cfg.SMTPPort)
|
|
}
|
|
|
|
if cfg.SMTPUsername == "" {
|
|
return fmt.Errorf("SMTP username is required")
|
|
}
|
|
|
|
if cfg.SMTPPassword == "" {
|
|
return fmt.Errorf("SMTP password is required")
|
|
}
|
|
|
|
if cfg.FromEmail == "" {
|
|
return fmt.Errorf("from email is required")
|
|
}
|
|
|
|
if cfg.FrontendURL == "" {
|
|
return fmt.Errorf("frontend URL is required")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// createSMTPClient создает SMTP клиент с правильными настройками
|
|
func createSMTPClient(cfg *config.Config) (*mail.Client, error) {
|
|
opts := []mail.Option{
|
|
mail.WithPort(cfg.SMTPPort),
|
|
mail.WithSMTPAuth(mail.SMTPAuthPlain),
|
|
mail.WithUsername(cfg.SMTPUsername),
|
|
mail.WithPassword(cfg.SMTPPassword),
|
|
}
|
|
|
|
// Настраиваем TLS в зависимости от порта
|
|
switch cfg.SMTPPort {
|
|
case 587:
|
|
// STARTTLS для порта 587
|
|
opts = append(opts, mail.WithTLSPolicy(mail.TLSMandatory))
|
|
case 465:
|
|
// SSL/TLS для порта 465
|
|
opts = append(opts, mail.WithSSL())
|
|
default:
|
|
// Opportunistic TLS для других портов
|
|
opts = append(opts, mail.WithTLSPolicy(mail.TLSOpportunistic))
|
|
}
|
|
|
|
return mail.NewClient(cfg.SMTPHost, opts...)
|
|
}
|
|
|
|
// loadTemplates загружает HTML шаблоны для писем
|
|
func loadTemplates() (*template.Template, error) {
|
|
tmpl := template.New("email")
|
|
|
|
templates := map[string]string{
|
|
"verification": verificationTemplate,
|
|
"password_reset": passwordResetTemplate,
|
|
"newsletter": newsletterTemplate,
|
|
}
|
|
|
|
for name, content := range templates {
|
|
var err error
|
|
tmpl, err = tmpl.New(name).Parse(content)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse template %s: %w", name, err)
|
|
}
|
|
}
|
|
|
|
return tmpl, nil
|
|
}
|
|
|
|
// IsActive возвращает статус сервиса
|
|
func (s *Service) IsActive() bool {
|
|
return s.isActive
|
|
}
|
|
|
|
// EmailData содержит данные для шаблонов писем
|
|
type EmailData struct {
|
|
UserName string
|
|
AppName string
|
|
FrontendURL string
|
|
Token string
|
|
Subject string
|
|
Content string
|
|
Year int
|
|
}
|
|
|
|
// SendVerificationEmail отправляет email для подтверждения адреса
|
|
func (s *Service) SendVerificationEmail(to, userName, token string) error {
|
|
if !s.isActive {
|
|
s.logger.Warn("Email service is disabled, skipping verification email",
|
|
zap.String("to", to), zap.String("user", userName))
|
|
return nil
|
|
}
|
|
|
|
s.logger.Info("Sending verification email", zap.String("to", to), zap.String("user", userName))
|
|
|
|
data := EmailData{
|
|
UserName: userName,
|
|
AppName: "Бегущий Башкир",
|
|
FrontendURL: s.config.FrontendURL,
|
|
Token: token,
|
|
Subject: "Подтверждение email",
|
|
Year: time.Now().Year(),
|
|
}
|
|
|
|
return s.sendEmail(to, "Подтверждение email - Бегущий Башкир", "verification", data)
|
|
}
|
|
|
|
// SendPasswordResetEmail отправляет email для сброса пароля
|
|
func (s *Service) SendPasswordResetEmail(to, userName, token string) error {
|
|
if !s.isActive {
|
|
s.logger.Warn("Email service is disabled, skipping password reset email",
|
|
zap.String("to", to), zap.String("user", userName))
|
|
return nil
|
|
}
|
|
|
|
s.logger.Info("Sending password reset email", zap.String("to", to), zap.String("user", userName))
|
|
|
|
data := EmailData{
|
|
UserName: userName,
|
|
AppName: "Бегущий Башкир",
|
|
FrontendURL: s.config.FrontendURL,
|
|
Token: token,
|
|
Subject: "Восстановление пароля",
|
|
Year: time.Now().Year(),
|
|
}
|
|
|
|
return s.sendEmail(to, "Восстановление пароля - Бегущий Башкир", "password_reset", data)
|
|
}
|
|
|
|
// SendNewsletterEmail отправляет email рассылку
|
|
func (s *Service) SendNewsletterEmail(to, userName, subject, content string) error {
|
|
if !s.isActive {
|
|
s.logger.Warn("Email service is disabled, skipping newsletter",
|
|
zap.String("to", to), zap.String("user", userName), zap.String("subject", subject))
|
|
return nil
|
|
}
|
|
|
|
s.logger.Info("Sending newsletter email",
|
|
zap.String("to", to),
|
|
zap.String("user", userName),
|
|
zap.String("subject", subject))
|
|
|
|
data := EmailData{
|
|
UserName: userName,
|
|
AppName: "Бегущий Башкир",
|
|
FrontendURL: s.config.FrontendURL,
|
|
Subject: subject,
|
|
Content: content,
|
|
Year: time.Now().Year(),
|
|
}
|
|
|
|
// Для новостей используем специальный шаблон
|
|
var body bytes.Buffer
|
|
if err := s.tmpl.ExecuteTemplate(&body, "newsletter", data); err != nil {
|
|
s.logger.Error("Failed to execute newsletter template", zap.Error(err))
|
|
return fmt.Errorf("failed to execute newsletter template: %w", err)
|
|
}
|
|
|
|
msg := mail.NewMsg()
|
|
if err := msg.From(s.fromAddr); err != nil {
|
|
return fmt.Errorf("failed to set from address: %w", err)
|
|
}
|
|
if err := msg.To(to); err != nil {
|
|
return fmt.Errorf("failed to set to address: %w", err)
|
|
}
|
|
|
|
msg.Subject(subject)
|
|
msg.SetBodyString(mail.TypeTextHTML, body.String())
|
|
|
|
if err := s.client.DialAndSend(msg); err != nil {
|
|
s.logger.Error("Failed to send newsletter email", zap.Error(err))
|
|
return fmt.Errorf("failed to send newsletter email: %w", err)
|
|
}
|
|
|
|
s.logger.Info("Newsletter email sent successfully", zap.String("to", to))
|
|
return nil
|
|
}
|
|
|
|
// sendEmail общий метод для отправки email
|
|
func (s *Service) sendEmail(to, subject, templateName string, data EmailData) error {
|
|
var body bytes.Buffer
|
|
if err := s.tmpl.ExecuteTemplate(&body, templateName, data); err != nil {
|
|
s.logger.Error("Failed to execute email template",
|
|
zap.String("template", templateName),
|
|
zap.Error(err))
|
|
return fmt.Errorf("failed to execute template %s: %w", templateName, err)
|
|
}
|
|
|
|
msg := mail.NewMsg()
|
|
if err := msg.From(s.fromAddr); err != nil {
|
|
return fmt.Errorf("failed to set from address: %w", err)
|
|
}
|
|
if err := msg.To(to); err != nil {
|
|
return fmt.Errorf("failed to set to address: %w", err)
|
|
}
|
|
|
|
msg.Subject(subject)
|
|
msg.SetBodyString(mail.TypeTextHTML, body.String())
|
|
|
|
if err := s.client.DialAndSend(msg); err != nil {
|
|
s.logger.Error("Failed to send email",
|
|
zap.String("type", templateName),
|
|
zap.String("to", to),
|
|
zap.Error(err))
|
|
return fmt.Errorf("failed to send email: %w", err)
|
|
}
|
|
|
|
s.logger.Info("Email sent successfully",
|
|
zap.String("type", templateName),
|
|
zap.String("to", to))
|
|
return nil
|
|
}
|
|
|
|
// TestConnection тестирует подключение к SMTP серверу
|
|
func (s *Service) TestConnection() error {
|
|
if !s.isActive {
|
|
return fmt.Errorf("email service is disabled")
|
|
}
|
|
|
|
// Создаем тестовое сообщение
|
|
msg := mail.NewMsg()
|
|
if err := msg.From(s.fromAddr); err != nil {
|
|
return fmt.Errorf("failed to set from address: %w", err)
|
|
}
|
|
if err := msg.To(s.fromAddr); err != nil {
|
|
return fmt.Errorf("failed to set to address: %w", err)
|
|
}
|
|
|
|
msg.Subject("Тестовое письмо - Бегущий Башкир")
|
|
msg.SetBodyString(mail.TypeTextPlain, "Это тестовое письмо для проверки подключения.")
|
|
|
|
// Пытаемся отправить тестовое письмо
|
|
if err := s.client.DialAndSend(msg); err != nil {
|
|
return fmt.Errorf("failed to send test email: %w", err)
|
|
}
|
|
|
|
s.logger.Info("SMTP connection test successful")
|
|
return nil
|
|
}
|
|
|
|
// Шаблоны писем остаются без изменений...
|
|
const verificationTemplate = `
|
|
{{define "verification"}}
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<style>
|
|
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }
|
|
.header { background: linear-gradient(135deg, #2e8b57, #3cb371); color: white; padding: 2rem; text-align: center; border-radius: 10px 10px 0 0; }
|
|
.content { padding: 2rem; background: #f8f9fa; }
|
|
.footer { padding: 1rem; text-align: center; color: #666; font-size: 0.9rem; }
|
|
.cta-button { display: inline-block; padding: 12px 24px; background: #2e8b57; color: white; text-decoration: none; border-radius: 5px; margin: 1rem 0; }
|
|
.token { background: #e9ecef; padding: 10px; border-radius: 5px; font-family: monospace; margin: 1rem 0; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="header">
|
|
<h1>🏃 Бегущий Башкир</h1>
|
|
<p>Подтверждение email адреса</p>
|
|
</div>
|
|
<div class="content">
|
|
<h2>Привет, {{.UserName}}!</h2>
|
|
<p>Благодарим за регистрацию в приложении "Бегущий Башкир". Для завершения регистрации подтвердите ваш email адрес.</p>
|
|
|
|
<a href="{{.FrontendURL}}/verify-email?token={{.Token}}" class="cta-button">
|
|
Подтвердить Email
|
|
</a>
|
|
|
|
<p>Или скопируйте эту ссылку в браузер:</p>
|
|
<div class="token">{{.FrontendURL}}/verify-email?token={{.Token}}</div>
|
|
|
|
<p>Ссылка действительна в течение 24 часов.</p>
|
|
</div>
|
|
<div class="footer">
|
|
<p>© {{.Year}} Бегущий Башкир. Все права защищены.</p>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
{{end}}
|
|
`
|
|
|
|
const passwordResetTemplate = `
|
|
{{define "password_reset"}}
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<style>
|
|
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }
|
|
.header { background: linear-gradient(135deg, #dc3545, #e35d6a); color: white; padding: 2rem; text-align: center; border-radius: 10px 10px 0 0; }
|
|
.content { padding: 2rem; background: #f8f9fa; }
|
|
.footer { padding: 1rem; text-align: center; color: #666; font-size: 0.9rem; }
|
|
.cta-button { display: inline-block; padding: 12px 24px; background: #dc3545; color: white; text-decoration: none; border-radius: 5px; margin: 1rem 0; }
|
|
.token { background: #e9ecef; padding: 10px; border-radius: 5px; font-family: monospace; margin: 1rem 0; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="header">
|
|
<h1>🏃 Бегущий Башкир</h1>
|
|
<p>Восстановление пароля</p>
|
|
</div>
|
|
<div class="content">
|
|
<h2>Привет, {{.UserName}}!</h2>
|
|
<p>Мы получили запрос на восстановление пароля для вашего аккаунта.</p>
|
|
|
|
<a href="{{.FrontendURL}}/reset-password?token={{.Token}}" class="cta-button">
|
|
Восстановить пароль
|
|
</a>
|
|
|
|
<p>Или скопируйте эту ссылку в браузер:</p>
|
|
<div class="token">{{.FrontendURL}}/reset-password?token={{.Token}}</div>
|
|
|
|
<p>Если вы не запрашивали восстановление пароля, просто проигнорируйте это письмо.</p>
|
|
<p>Ссылка действительна в течение 1 часа.</p>
|
|
</div>
|
|
<div class="footer">
|
|
<p>© {{.Year}} Бегущий Башкир. Все права защищены.</p>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
{{end}}
|
|
`
|
|
|
|
const newsletterTemplate = `
|
|
{{define "newsletter"}}
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<style>
|
|
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }
|
|
.header { background: linear-gradient(135deg, #2e8b57, #3cb371); color: white; padding: 2rem; text-align: center; border-radius: 10px 10px 0 0; }
|
|
.content { padding: 2rem; background: #f8f9fa; }
|
|
.footer { padding: 1rem; text-align: center; color: #666; font-size: 0.9rem; }
|
|
.newsletter-content { line-height: 1.8; }
|
|
.cta-button { display: inline-block; padding: 12px 24px; background: #2e8b57; color: white; text-decoration: none; border-radius: 5px; margin: 1rem 0; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="header">
|
|
<h1>🏃 Бегущий Башкир</h1>
|
|
<p>Новости и обновления</p>
|
|
</div>
|
|
<div class="content">
|
|
<h2>Привет, {{.UserName}}!</h2>
|
|
<div class="newsletter-content">
|
|
{{.Content}}
|
|
</div>
|
|
</div>
|
|
<div class="footer">
|
|
<p>© {{.Year}} Бегущий Башкир. Все права защищены.</p>
|
|
<p><a href="{{.FrontendURL}}/unsubscribe" style="color: #666;">Отписаться от рассылки</a></p>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
{{end}}
|
|
`
|