modified: serv_nginx/api_bb/.env

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
This commit is contained in:
2025-10-22 05:16:30 +05:00
parent 64301b9135
commit 1e678c4b7e
14 changed files with 1166 additions and 25 deletions
+8
View File
@@ -14,3 +14,11 @@ ENVIRONMENT=development
# app
REST_API_VERSION=1.0.0
VITE_API_BASE_URL=https://begushiybashkir.ru
# Email Configuration
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USERNAME=valitovgaziz
SMTP_PASSWORD=omqywxnamignyeql
FROM_EMAIL=valitovgaziz@gmail.com
FRONTEND_URL=https://begushiybashkir.ru
+2
View File
@@ -23,6 +23,7 @@ require (
require (
github.com/go-playground/validator/v10 v10.28.0
github.com/google/uuid v1.6.0
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.6.0 // indirect
@@ -30,6 +31,7 @@ require (
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/joho/godotenv v1.5.1
github.com/wneessen/go-mail v0.7.2
go.uber.org/zap v1.27.0
golang.org/x/sync v0.17.0 // indirect
golang.org/x/text v0.30.0 // indirect
+4
View File
@@ -17,6 +17,8 @@ github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
@@ -40,6 +42,8 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/wneessen/go-mail v0.7.2 h1:xxPnhZ6IZLSgxShebmZ6DPKh1b6OJcoHfzy7UjOkzS8=
github.com/wneessen/go-mail v0.7.2/go.mod h1:+TkW6QP3EVkgTEqHtVmnAE/1MRhmzb8Y9/W3pweuS+k=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
@@ -26,6 +26,7 @@ func (d *Database) Migrate() error {
&models.EventRegistration{},
&models.PersonalBest{},
&models.TrainingPlan{},
&models.EmailVerification{},
// Добавьте другие модели здесь
}
@@ -96,6 +97,8 @@ func getModelName(model interface{}) string {
return "Персональные достижения"
case *models.TrainingPlan:
return "Тренировочный план"
case *models.EmailVerification:
return "Верификация email"
default:
return "Unknown"
}
+16 -7
View File
@@ -18,16 +18,18 @@ import (
)
type AuthHandler struct {
authService service.AuthService
jwtService service.JWTService
logger logger.LoggerInterface
authService service.AuthService
jwtService service.JWTService
logger logger.LoggerInterface
emailService service.EmailService
}
func NewAuthHandler(authService service.AuthService, jwtService service.JWTService) *AuthHandler {
func NewAuthHandler(authService service.AuthService, jwtService service.JWTService, emailService service.EmailService) *AuthHandler {
return &AuthHandler{
authService: authService,
jwtService: jwtService,
logger: logger.NewWrapper(logger.Get().With(zap.String("handler", "auth"))),
authService: authService,
jwtService: jwtService,
logger: logger.NewWrapper(logger.Get().With(zap.String("handler", "auth"))),
emailService: emailService,
}
}
@@ -136,6 +138,13 @@ func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
zap.String("email", user.Email),
)
// Отправки сообщения для верификации Email
if err := h.emailService.SendVerificationEmail(user.ID, user.Email, user.FirstName); err != nil {
h.logger.Error("failed to send verification email",
zap.Error(err),
zap.Uint("user_id", user.ID))
}
// После успешной регистрации возвращаем данные пользователя
utils.RespondWithJSON(w, http.StatusCreated, map[string]interface{}{
"message": "User registered successfully",
@@ -0,0 +1,223 @@
// handlers/email_handler.go
package handlers
import (
"net/http"
"api_bb/internal/models"
"api_bb/internal/service"
"api_bb/pkg/logger"
"api_bb/pkg/middleware"
"api_bb/pkg/utils"
"go.uber.org/zap"
)
type EmailHandler struct {
logger logger.LoggerInterface
emailService *service.EmailService
}
func NewEmailHandler(emailService *service.EmailService) *EmailHandler {
return &EmailHandler{
logger: logger.NewWrapper(logger.Get().With(zap.String("handler", "email"))),
emailService: emailService,
}
}
// VerifyEmail подтверждает email пользователя
func (h *EmailHandler) VerifyEmail(w http.ResponseWriter, r *http.Request) {
h.logger.Info("handling email verification request",
zap.String("method", r.Method),
zap.String("path", r.URL.Path),
zap.String("remote_addr", r.RemoteAddr),
)
token := r.URL.Query().Get("token")
if token == "" {
h.logger.Warn("email verification failed - token is required")
utils.RespondWithError(w, http.StatusBadRequest, "Токен обязателен")
return
}
if err := h.emailService.VerifyEmail(token); err != nil {
h.logger.Error("email verification failed, expired",
zap.Error(err),
zap.String("token", token),
)
utils.RespondWithError(w, http.StatusBadRequest, "Неверный или просроченный токен")
return
}
h.logger.Info("email successfully verified",
zap.String("token", token),
)
utils.RespondWithJSON(w, http.StatusOK, map[string]string{
"message": "Email успешно подтвержден",
})
}
// RequestPasswordReset запрашивает сброс пароля
func (h *EmailHandler) RequestPasswordReset(w http.ResponseWriter, r *http.Request) {
h.logger.Info("handling password reset request",
zap.String("method", r.Method),
zap.String("path", r.URL.Path),
zap.String("remote_addr", r.RemoteAddr),
)
var req models.PasswordResetRequest
if err := utils.DecodeJSONBody(w, r, &req); err != nil {
h.logger.Warn("password reset request failed - invalid request format",
zap.Error(err),
)
utils.RespondWithError(w, http.StatusBadRequest, "Неверный формат запроса")
return
}
if err := h.emailService.SendPasswordResetEmail(req.Email); err != nil {
h.logger.Error("password reset request failed",
zap.Error(err),
zap.String("email", req.Email),
)
// Для безопасности всегда возвращаем успех
}
h.logger.Info("password reset request processed",
zap.String("email", req.Email),
)
utils.RespondWithJSON(w, http.StatusOK, map[string]string{
"message": "Если email зарегистрирован, инструкции по восстановлению пароля будут отправлены",
})
}
// ConfirmPasswordReset подтверждает сброс пароля
func (h *EmailHandler) ConfirmPasswordReset(w http.ResponseWriter, r *http.Request) {
h.logger.Info("handling password reset confirmation request",
zap.String("method", r.Method),
zap.String("path", r.URL.Path),
zap.String("remote_addr", r.RemoteAddr),
)
var req models.PasswordResetConfirm
if err := utils.DecodeJSONBody(w, r, &req); err != nil {
h.logger.Warn("password reset confirmation failed - invalid request format",
zap.Error(err),
)
utils.RespondWithError(w, http.StatusBadRequest, "Неверный формат запроса")
return
}
if err := h.emailService.ResetPassword(req.Token, req.Password); err != nil {
h.logger.Error("password reset confirmation failed",
zap.Error(err),
zap.String("token", req.Token),
)
utils.RespondWithError(w, http.StatusBadRequest, "Неверный или просроченный токен")
return
}
h.logger.Info("password successfully reset",
zap.String("token", req.Token),
)
utils.RespondWithJSON(w, http.StatusOK, map[string]string{
"message": "Пароль успешно изменен",
})
}
type NewsletterRequest struct {
Subject string `json:"subject" validate:"required"`
Content string `json:"content" validate:"required"`
}
// SendNewsletter отправляет рассылку новостей
func (h *EmailHandler) SendNewsletter(w http.ResponseWriter, r *http.Request) {
h.logger.Info("handling newsletter sending request",
zap.String("method", r.Method),
zap.String("path", r.URL.Path),
zap.String("remote_addr", r.RemoteAddr),
)
var req NewsletterRequest
if err := utils.DecodeJSONBody(w, r, &req); err != nil {
h.logger.Warn("newsletter sending failed - invalid request format",
zap.Error(err),
)
utils.RespondWithError(w, http.StatusBadRequest, "Неверный формат запроса")
return
}
if err := h.emailService.SendNewsletterToSubscribers(req.Subject, req.Content); err != nil {
h.logger.Error("newsletter sending failed",
zap.Error(err),
zap.String("subject", req.Subject),
)
utils.RespondWithError(w, http.StatusInternalServerError, "Не удалось отправить рассылку")
return
}
h.logger.Info("newsletter sent successfully",
zap.String("subject", req.Subject),
)
utils.RespondWithJSON(w, http.StatusOK, map[string]string{
"message": "Рассылка отправлена подписчикам",
})
}
// ResendVerification повторно отправляет email верификации
func (h *EmailHandler) ResendVerification(w http.ResponseWriter, r *http.Request) {
h.logger.Info("handling resend verification request",
zap.String("method", r.Method),
zap.String("path", r.URL.Path),
zap.String("remote_addr", r.RemoteAddr),
)
user, ok := middleware.GetUserFromContext(r.Context())
if !ok {
h.logger.Warn("resend verification failed - authentication required")
utils.RespondWithError(w, http.StatusUnauthorized, "Пользователь не авторизован")
return
}
// Получаем пользователя
userData, err := h.emailService.GetUserByID(user.ID)
if err != nil {
h.logger.Warn("resend verification failed - user not found",
zap.Uint("user_id", user.ID),
zap.Error(err),
)
utils.RespondWithError(w, http.StatusNotFound, "Пользователь не найден")
return
}
if userData.EmailVerified {
h.logger.Warn("resend verification failed - email already verified",
zap.Uint("user_id", user.ID),
zap.String("email", userData.Email),
)
utils.RespondWithError(w, http.StatusBadRequest, "Email уже подтвержден")
return
}
if err := h.emailService.SendVerificationEmail(userData.ID, userData.Email, userData.FirstName); err != nil {
h.logger.Error("resend verification failed",
zap.Error(err),
zap.Uint("user_id", user.ID),
zap.String("email", userData.Email),
)
utils.RespondWithError(w, http.StatusInternalServerError, "Не удалось отправить email подтверждения")
return
}
h.logger.Info("verification email resent successfully",
zap.Uint("user_id", user.ID),
zap.String("email", userData.Email),
)
utils.RespondWithJSON(w, http.StatusOK, map[string]string{
"message": "Email подтверждения отправлен повторно",
})
}
@@ -5,8 +5,10 @@ import (
"api_bb/internal/config"
"api_bb/internal/repository"
"api_bb/internal/service"
"api_bb/pkg/email"
"api_bb/pkg/logger"
"go.uber.org/zap"
"gorm.io/gorm"
)
@@ -24,6 +26,7 @@ type Handler struct {
eventRegistrationHandler *EventRegistrationHandler
personalBestHandler *PersonalBestHandler
trainingPlanHandler *TrainingPlanHandler
emailHandler *EmailHandler
// Здесь будут добавлены другие обработчики
// userHandler *UserHandler
// eventHandler *EventHandler
@@ -43,10 +46,17 @@ func NewHandler(db *gorm.DB, cfg *config.Config) *Handler {
eventRegistrationRepo := repository.NewEventRegistrationRepository(db)
personalBestRepo := repository.NewPersonalBestRepository(db)
trainingPlanRepo := repository.NewTrainingPlanRepository(db)
emailRepo := repository.NewEmailRepository(db)
// Initialize logger
baseLogger := logger.NewWrapper(logger.Get()) // Создаем базовый логгер
// getConfig
emailSender, err := email.NewService(config.Load())
if err != nil {
baseLogger.Info("error to load config", zap.Error(err))
}
// Инициализация сервисов
jwtService := service.NewJWTService(cfg.JWTSecret)
authService := service.NewAuthService(userRepo, jwtService, baseLogger)
@@ -61,10 +71,11 @@ func NewHandler(db *gorm.DB, cfg *config.Config) *Handler {
eventService := service.NewEventService(eventRepo, eventRegistrationRepo, baseLogger)
personalBestService := service.NewPersonalBestService(personalBestRepo, userStatsService)
trainingPlanService := service.NewTrainingPlanService(*trainingPlanRepo)
emailService := service.NewEmailService(*emailRepo, userRepo, *emailSender)
// Инициализация обработчиков
healthHandler := NewHealthHandler()
authHandler := NewAuthHandler(authService, jwtService)
authHandler := NewAuthHandler(authService, jwtService, emailService)
userHandler := NewUserHandler(&userService)
newsHandler := NewNewsHandler(newsService, baseLogger)
avatarHandler := NewAvatarHandler(avatarService)
@@ -76,6 +87,7 @@ func NewHandler(db *gorm.DB, cfg *config.Config) *Handler {
eventRegistrationHandler := NewEventRegistrationHandler(eventRegistrationService)
personalBestHandler := NewPersonalBestHandler(*personalBestService)
trainingPlanHandler := NewTrainingPlanHandler(trainingPlanService)
emailHandler := NewEmailHandler(&emailService)
return &Handler{
healthHandler: healthHandler,
@@ -91,10 +103,15 @@ func NewHandler(db *gorm.DB, cfg *config.Config) *Handler {
eventRegistrationHandler: eventRegistrationHandler,
personalBestHandler: personalBestHandler,
trainingPlanHandler: trainingPlanHandler,
emailHandler: emailHandler,
}
}
// Геттеры для обработчиков (опционально, для удобства)
func (h *Handler) EmailHandler() *EmailHandler {
return h.emailHandler
}
func (h *Handler) TrainingPlanHandler() *TrainingPlanHandler {
return h.trainingPlanHandler
}
+16 -14
View File
@@ -10,20 +10,22 @@ import (
// models/user.go - добавить поле Avatar
type User struct {
ID uint `json:"id" gorm:"primaryKey"`
Email string `json:"email" gorm:"uniqueIndex;not null"`
Password string `json:"-" gorm:"not null"`
FirstName string `json:"first_name" gorm:"not null"`
LastName string `json:"last_name" gorm:"not null"`
Avatar string `json:"avatar"` // Путь к файлу аватара
Phone string `json:"phone"`
Experience string `json:"experience"`
Goals string `json:"goals"`
Newsletter bool `json:"newsletter"`
Role string `json:"role" gorm:"default:user"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
ID uint `json:"id" gorm:"primaryKey"`
Email string `json:"email" gorm:"uniqueIndex;not null"`
Password string `json:"-" gorm:"not null"`
FirstName string `json:"first_name" gorm:"not null"`
LastName string `json:"last_name" gorm:"not null"`
Avatar string `json:"avatar"` // Путь к файлу аватара
Phone string `json:"phone"`
Experience string `json:"experience"`
Goals string `json:"goals"`
Newsletter bool `json:"newsletter"`
Role string `json:"role" gorm:"default:user"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
EmailVerified bool `json:"email_verified" gorm:"default:false"`
VerifiedAt time.Time `json:"verified_at"`
// Связи
Workouts []Workout `json:"workouts,omitempty" gorm:"foreignKey:UserID"`
@@ -0,0 +1,83 @@
// repository/email_repository.go
package repository
import (
"time"
"api_bb/internal/models"
"gorm.io/gorm"
)
type EmailRepository struct {
db *gorm.DB
}
func NewEmailRepository(db *gorm.DB) *EmailRepository {
return &EmailRepository{db: db}
}
func (r *EmailRepository) CreateVerificationToken(verification *models.EmailVerification) error {
return r.db.Create(verification).Error
}
func (r *EmailRepository) GetVerificationToken(token string) (*models.EmailVerification, error) {
var verification models.EmailVerification
err := r.db.Where("token = ? AND used = ? AND expires_at > ?", token, false, time.Now()).
Preload("User").
First(&verification).Error
if err != nil {
return nil, err
}
return &verification, nil
}
func (r *EmailRepository) MarkTokenAsUsed(token string) error {
return r.db.Model(&models.EmailVerification{}).
Where("token = ?", token).
Updates(map[string]interface{}{
"used": true,
"updated_at": time.Now(),
}).Error
}
func (r *EmailRepository) DeleteExpiredTokens() error {
return r.db.Where("expires_at < ?", time.Now()).Delete(&models.EmailVerification{}).Error
}
func (r *EmailRepository) GetUsersWithNewsletter() ([]models.User, error) {
var users []models.User
err := r.db.Where("newsletter = ? AND email_verified = ?", true, true).
Find(&users).Error
return users, err
}
// MarkEmailAsVerified помечает email пользователя как верифицированный
func (r *EmailRepository) MarkEmailAsVerified(userID uint) error {
return r.db.Model(&models.User{}).
Where("id = ?", userID).
Updates(map[string]interface{}{
"email_verified": true,
"updated_at": time.Now(),
}).Error
}
// GetUserByEmail возвращает пользователя по email
func (r *EmailRepository) GetUserByEmail(email string) (*models.User, error) {
var user models.User
err := r.db.Where("email = ?", email).First(&user).Error
if err != nil {
return nil, err
}
return &user, nil
}
// UpdatePassword обновляет пароль пользователя
func (r *EmailRepository) UpdatePassword(userID uint, newPassword string) error {
return r.db.Model(&models.User{}).
Where("id = ?", userID).
Updates(map[string]interface{}{
"password": newPassword,
"updated_at": time.Now(),
}).Error
}
@@ -16,6 +16,9 @@ type UserRepository interface {
UpdateExcludeEmail(userUpdate *models.User) error
UpdateAvatar(userID uint, avatarPath string) error
FindAll() ([]models.User, error)
MarkEmailAsVerified(userID uint) error
UpdatePassword(userID uint, newPassword string) error
GetUserByID(id uint) (*models.User, error)
}
func (r *userRepository) UpdateAvatar(userID uint, avatarPath string) error {
@@ -91,3 +94,33 @@ func (r *userRepository) UpdateExcludeEmail(userUpdate *models.User) error {
return nil
}
// MarkEmailAsVerified помечает email пользователя как верифицированный
func (r userRepository) MarkEmailAsVerified(userID uint) error {
result := r.db.Model(&models.User{}).Where("id = ?", userID).Update("email_verified", true)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return fmt.Errorf("user not found")
}
return nil
}
// UpdatePassword обновляет пароль пользователя
func (r userRepository) UpdatePassword(userID uint, newPassword string) error {
result := r.db.Model(&models.User{}).Where("id = ?", userID).Update("password", newPassword)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return fmt.Errorf("user not found")
}
return nil
}
func (r userRepository) GetUserByID(id uint) (*models.User, error) {
var user models.User
err := r.db.First(&user, id).Error
return &user, err
}
+19 -2
View File
@@ -34,12 +34,16 @@ func SetupRouter(db *gorm.DB, config *config.Config) http.Handler {
userRepo := repository.NewUserRepository(db)
// Initialize logger
baseLogger := logger.NewWrapper(logger.Get()) // Создаем базовый логгер
baseLogger := logger.NewWrapper(logger.Get())
// Initialize services with logger
jwtService := service.NewJWTService(config.JWTSecret)
// Initialize handlers
// Email service initialization with fallback
var emailHandler *handlers.EmailHandler
if h.EmailHandler() != nil {
emailHandler = h.EmailHandler()
}
// Health routes
r.Route("/api", func(r chi.Router) {
@@ -50,11 +54,23 @@ func SetupRouter(db *gorm.DB, config *config.Config) http.Handler {
// API v1 routes
r.Route("/v1", func(r chi.Router) {
// Email verification (public) - только если доступен
if emailHandler != nil {
r.Get("/verify-email", emailHandler.VerifyEmail)
}
// Public auth routes
r.Route("/auth", func(r chi.Router) {
r.Post("/register", h.AuthHandler().Register)
r.Post("/login", h.AuthHandler().Login)
r.Post("/logout", h.AuthHandler().Logout)
// Email routes (only if email handler is available)
if emailHandler != nil {
r.Post("/verify-email/resend", emailHandler.ResendVerification)
r.Post("/password-reset/request", emailHandler.RequestPasswordReset)
r.Post("/password-reset/confirm", emailHandler.ConfirmPasswordReset)
}
})
// Публичные маршруты для достижений (если нужны)
@@ -238,6 +254,7 @@ func SetupRouter(db *gorm.DB, config *config.Config) http.Handler {
})
})
// Events
r.Route("/events", func(r chi.Router) {
// Публичные маршруты
@@ -0,0 +1,297 @@
// service/email_service.go
package service
import (
"fmt"
"time"
"api_bb/internal/models"
"api_bb/internal/repository"
"api_bb/pkg/email"
"api_bb/pkg/logger"
"github.com/google/uuid"
"go.uber.org/zap"
)
type EmailService struct {
emailRepo repository.EmailRepository
userRepo repository.UserRepository
emailSender email.Service
logger *zap.Logger
tokenExpiry time.Duration
passwordExpiry time.Duration
}
func NewEmailService(
emailRepo repository.EmailRepository,
userRepo repository.UserRepository,
emailSender email.Service,
) EmailService {
// Создаем логгер с контекстом для сервиса
serviceLogger := logger.Get().With(zap.String("service", "email"))
return EmailService{
emailRepo: emailRepo,
userRepo: userRepo,
emailSender: emailSender,
logger: serviceLogger,
tokenExpiry: 24 * time.Hour, // 24 часа для верификации
passwordExpiry: 1 * time.Hour, // 1 час для сброса пароля
}
}
func (s *EmailService) SendVerificationEmail(userID uint, email, userName string) error {
s.logger.Info("Sending verification email",
zap.Uint("user_id", userID),
zap.String("email", email),
)
token := uuid.New().String()
verification := &models.EmailVerification{
UserID: userID,
Token: token,
Email: email,
Type: "verification",
ExpiresAt: time.Now().Add(s.tokenExpiry),
}
if err := s.emailRepo.CreateVerificationToken(verification); err != nil {
s.logger.Error("Failed to create verification token",
zap.Uint("user_id", userID),
zap.String("email", email),
zap.Error(err),
)
return fmt.Errorf("failed to create verification token: %w", err)
}
if err := s.emailSender.SendVerificationEmail(email, userName, token); err != nil {
s.logger.Error("Failed to send verification email",
zap.Uint("user_id", userID),
zap.String("email", email),
zap.Error(err),
)
return fmt.Errorf("failed to send verification email: %w", err)
}
s.logger.Info("Verification email sent successfully",
zap.Uint("user_id", userID),
zap.String("email", email))
return nil
}
func (s *EmailService) VerifyEmail(token string) error {
s.logger.Info("Verifying email token",
zap.String("token", token),
)
verification, err := s.emailRepo.GetVerificationToken(token)
if err != nil {
s.logger.Error("Invalid or expired verification token",
zap.String("token", token),
zap.Error(err),
)
return fmt.Errorf("invalid or expired token: %w", err)
}
if verification.Type != "verification" {
s.logger.Error("Invalid token type for email verification",
zap.String("token", token),
zap.String("type", verification.Type),
)
return fmt.Errorf("invalid token type")
}
// Обновляем пользователя
if err := s.userRepo.MarkEmailAsVerified(verification.UserID); err != nil {
s.logger.Error("Failed to verify email in user repository",
zap.Uint("user_id", verification.UserID),
zap.String("email", verification.Email),
zap.Error(err),
)
return fmt.Errorf("failed to verify email: %w", err)
}
// Помечаем токен как использованный
if err := s.emailRepo.MarkTokenAsUsed(token); err != nil {
s.logger.Error("Failed to mark token as used",
zap.Error(err),
zap.String("token", token))
}
s.logger.Info("Email verified successfully",
zap.Uint("user_id", verification.UserID),
zap.String("email", verification.Email))
return nil
}
func (s *EmailService) SendPasswordResetEmail(email string) error {
s.logger.Info("Sending password reset email",
zap.String("email", email),
)
user, err := s.userRepo.FindByEmail(email)
if err != nil {
// Для безопасности не сообщаем, существует ли email
s.logger.Info("Password reset requested for non-existent email",
zap.String("email", email))
return nil
}
token := uuid.New().String()
resetRequest := &models.EmailVerification{
UserID: user.ID,
Token: token,
Email: email,
Type: "password_reset",
ExpiresAt: time.Now().Add(s.passwordExpiry),
}
if err := s.emailRepo.CreateVerificationToken(resetRequest); err != nil {
s.logger.Error("Failed to create password reset token",
zap.Uint("user_id", user.ID),
zap.String("email", email),
zap.Error(err),
)
return fmt.Errorf("failed to create password reset token: %w", err)
}
if err := s.emailSender.SendPasswordResetEmail(email, user.FirstName, token); err != nil {
s.logger.Error("Failed to send password reset email",
zap.Uint("user_id", user.ID),
zap.String("email", email),
zap.Error(err),
)
return fmt.Errorf("failed to send password reset email: %w", err)
}
s.logger.Info("Password reset email sent successfully",
zap.Uint("user_id", user.ID),
zap.String("email", email))
return nil
}
func (s *EmailService) ResetPassword(token, newPassword string) error {
s.logger.Info("Resetting password with token",
zap.String("token", token),
)
verification, err := s.emailRepo.GetVerificationToken(token)
if err != nil {
s.logger.Error("Invalid or expired password reset token",
zap.String("token", token),
zap.Error(err),
)
return fmt.Errorf("invalid or expired token: %w", err)
}
if verification.Type != "password_reset" {
s.logger.Error("Invalid token type for password reset",
zap.String("token", token),
zap.String("type", verification.Type),
)
return fmt.Errorf("invalid token type")
}
// Обновляем пароль пользователя
if err := s.userRepo.UpdatePassword(verification.UserID, newPassword); err != nil {
s.logger.Error("Failed to update password",
zap.Uint("user_id", verification.UserID),
zap.Error(err),
)
return fmt.Errorf("failed to update password: %w", err)
}
// Помечаем токен как использованный
if err := s.emailRepo.MarkTokenAsUsed(token); err != nil {
s.logger.Error("Failed to mark token as used",
zap.Error(err),
zap.String("token", token))
}
s.logger.Info("Password reset successfully",
zap.Uint("user_id", verification.UserID))
return nil
}
func (s *EmailService) SendNewsletterToSubscribers(subject, content string) error {
s.logger.Info("Sending newsletter to subscribers",
zap.String("subject", subject),
)
subscribers, err := s.emailRepo.GetUsersWithNewsletter()
if err != nil {
s.logger.Error("Failed to get subscribers",
zap.Error(err),
)
return fmt.Errorf("failed to get subscribers: %w", err)
}
s.logger.Debug("Found subscribers for newsletter",
zap.Int("count", len(subscribers)),
)
var errors []error
for _, user := range subscribers {
if err := s.emailSender.SendNewsletterEmail(user.Email, user.FirstName, subject, content); err != nil {
s.logger.Error("Failed to send newsletter to user",
zap.Uint("user_id", user.ID),
zap.String("email", user.Email),
zap.Error(err))
errors = append(errors, err)
continue
}
s.logger.Debug("Newsletter sent to user",
zap.Uint("user_id", user.ID),
zap.String("email", user.Email))
}
if len(errors) > 0 {
s.logger.Error("Failed to send newsletter to some users",
zap.Int("failed_count", len(errors)),
zap.Int("total_subscribers", len(subscribers)),
)
return fmt.Errorf("failed to send newsletter to %d users", len(errors))
}
s.logger.Info("Newsletter sent to all subscribers",
zap.Int("total_subscribers", len(subscribers)))
return nil
}
func (s *EmailService) CleanupExpiredTokens() error {
s.logger.Info("Cleaning up expired tokens")
if err := s.emailRepo.DeleteExpiredTokens(); err != nil {
s.logger.Error("Failed to cleanup expired tokens",
zap.Error(err),
)
return fmt.Errorf("failed to cleanup expired tokens: %w", err)
}
s.logger.Info("Expired tokens cleaned up successfully")
return nil
}
// GetUserByID возвращает пользователя по ID
func (s *EmailService) GetUserByID(userID uint) (*models.User, error) {
s.logger.Info("Getting user by ID",
zap.Uint("user_id", userID),
)
user, err := s.userRepo.GetUserByID(userID)
if err != nil {
s.logger.Error("Failed to get user by ID",
zap.Uint("user_id", userID),
zap.Error(err),
)
return nil, fmt.Errorf("failed to get user: %w", err)
}
s.logger.Debug("User retrieved successfully",
zap.Uint("user_id", userID),
zap.String("email", user.Email),
)
return user, nil
}
@@ -135,3 +135,5 @@ func (s *userService) GetUserProfile(userID uint) (*models.User, error) {
return user, nil
}
+441
View File
@@ -0,0 +1,441 @@
// 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}}
`