1e678c4b7e
modified: serv_nginx/api_bb/go.mod modified: serv_nginx/api_bb/go.sum modified: serv_nginx/api_bb/internal/database/migrate.go modified: serv_nginx/api_bb/internal/handlers/auth.go new file: serv_nginx/api_bb/internal/handlers/email_handler.go modified: serv_nginx/api_bb/internal/handlers/handlers.go modified: serv_nginx/api_bb/internal/models/user.go new file: serv_nginx/api_bb/internal/repository/email_repository.go modified: serv_nginx/api_bb/internal/repository/user_repository.go modified: serv_nginx/api_bb/internal/routes/routes.go new file: serv_nginx/api_bb/internal/service/email_service.go modified: serv_nginx/api_bb/internal/service/user_service.go new file: serv_nginx/api_bb/pkg/email/email.go add email sender, vrificator and reset password
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}}
|
|
`
|