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:
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -21,13 +21,15 @@ type AuthHandler struct {
|
||||
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"))),
|
||||
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
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ type User struct {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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}}
|
||||
`
|
||||
Reference in New Issue
Block a user