From 15c140db319b2017e1088d1f5731864a138e12ca Mon Sep 17 00:00:00 2001 From: Nikolai Papin Date: Wed, 9 Jul 2025 23:26:30 +0300 Subject: [PATCH] feat: implemented smtp service; feat: implemented registration emails; fix: config variables for password length used the same env variable; refactor: all available config variables added to docker-compose.yml --- backend/cmd/main.go | 3 +- backend/config/config.go | 2 +- backend/internal/errors/smtp.go | 2 +- backend/internal/models/auth.go | 2 +- backend/internal/services/auth.go | 40 +++++++--- backend/internal/services/setup.go | 1 + backend/internal/services/smtp.go | 116 +++++++++++++++++++++++++++-- docker-compose.yml | 26 ++++++- 8 files changed, 171 insertions(+), 21 deletions(-) diff --git a/backend/cmd/main.go b/backend/cmd/main.go index e2071de..7a8f329 100644 --- a/backend/cmd/main.go +++ b/backend/cmd/main.go @@ -33,6 +33,7 @@ import ( "context" "fmt" "net/http" + "strconv" "time" "github.com/gin-gonic/gin" @@ -79,7 +80,7 @@ func main() { // Gin server := &http.Server{ - Addr: fmt.Sprintf(":%s", config.GetConfig().Port), + Addr: fmt.Sprintf(":%s", strconv.Itoa(int(config.GetConfig().Port))), Handler: router, } diff --git a/backend/config/config.go b/backend/config/config.go index 410aeb3..ec47c35 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -50,7 +50,7 @@ type Config struct { SmtpTimeout uint `mapstructure:"SMTP_TIMEOUT"` PasswordMinLength int `mapstructure:"PASSWORD_MIN_LENGTH"` - PasswordMaxLength int `mapstructure:"PASSWORD_MIN_LENGTH"` + PasswordMaxLength int `mapstructure:"PASSWORD_MAX_LENGTH"` PasswordCheckNumbers bool `mapstructure:"PASSWORD_CHECK_NUMBERS"` PasswordCheckCharacters bool `mapstructure:"PASSWORD_CHECK_CHARACTERS"` PasswordCheckCases bool `mapstructure:"PASSWORD_CHECK_CASES"` diff --git a/backend/internal/errors/smtp.go b/backend/internal/errors/smtp.go index 4b45f59..14a5524 100644 --- a/backend/internal/errors/smtp.go +++ b/backend/internal/errors/smtp.go @@ -6,5 +6,5 @@ import ( var ( ErrSmtpDisabled = errors.New("Smtp is not enabled in the config") - ErrSmtpSendFailure = errors.New("Failed to send email") + ErrSmtpMissingConfiguration = errors.New("Some necessary SMTP configuration is missing") ) diff --git a/backend/internal/models/auth.go b/backend/internal/models/auth.go index 6690e6c..b94bec2 100644 --- a/backend/internal/models/auth.go +++ b/backend/internal/models/auth.go @@ -24,7 +24,7 @@ type Tokens struct { type RegistrationBeginRequest struct { Username string `json:"username" binding:"required" validate:"username"` - Email *string `json:"email" binding:"email"` + Email string `json:"email" binding:"required,email"` Password string `json:"password" binding:"required" validate:"password"` } diff --git a/backend/internal/services/auth.go b/backend/internal/services/auth.go index 71ab9bd..a416d2d 100644 --- a/backend/internal/services/auth.go +++ b/backend/internal/services/auth.go @@ -18,12 +18,14 @@ package services import ( + "easywish/config" "easywish/internal/database" errs "easywish/internal/errors" "easywish/internal/models" "easywish/internal/utils" "easywish/internal/utils/enums" "errors" + "fmt" "github.com/jackc/pgerrcode" "github.com/jackc/pgx/v5" @@ -39,11 +41,12 @@ type AuthService interface { type authServiceImpl struct { log *zap.Logger + smtp SmtpService dbctx database.DbContext } -func NewAuthService(_log *zap.Logger, _dbctx database.DbContext) AuthService { - return &authServiceImpl{log: _log, dbctx: _dbctx} +func NewAuthService(_log *zap.Logger, _dbctx database.DbContext, _smtp SmtpService) AuthService { + return &authServiceImpl{log: _log, dbctx: _dbctx, smtp: _smtp} } func (a *authServiceImpl) RegistrationBegin(request models.RegistrationBeginRequest) (bool, error) { @@ -81,7 +84,7 @@ func (a *authServiceImpl) RegistrationBegin(request models.RegistrationBeginRequ if _, err = db.TXQueries.CreateLoginInformation(db.CTX, database.CreateLoginInformationParams{ UserID: user.ID, - Email: request.Email, + Email: utils.NewPointer(request.Email), PasswordHash: passwordHash, // Hashed in database }); err != nil { @@ -117,16 +120,31 @@ func (a *authServiceImpl) RegistrationBegin(request models.RegistrationBeginRequ zap.String("username", user.Username), zap.Int64("id", user.ID)) + if config.GetConfig().SmtpEnabled { + + if err := a.smtp.SendEmail( + request.Email, + "Easywish", + fmt.Sprintf("Your registration code is %s", generatedCode), + ); err != nil { + a.log.Error( + "Failed to send registration email", + zap.String("email", request.Email), + zap.String("username", request.Username), + zap.Error(err)) + + return false, errs.ErrServerError + } + + } else { + a.log.Debug( + "Declated registration code for a new user. Enable SMTP in the config to disable this message", + zap.String("username", user.Username), + zap.String("code", generatedCode)) + } + helper.Commit() - // TODO: get rid of this when email verification will start working - a.log.Debug( - "Declated registration code for a new user", - zap.String("username", user.Username), - zap.String("code", generatedCode)) - - // TODO: Send verification email - return true, nil } diff --git a/backend/internal/services/setup.go b/backend/internal/services/setup.go index 5589100..dbe2db1 100644 --- a/backend/internal/services/setup.go +++ b/backend/internal/services/setup.go @@ -23,6 +23,7 @@ import ( var Module = fx.Module("services", fx.Provide( + NewSmtpService, NewAuthService, ), ) diff --git a/backend/internal/services/smtp.go b/backend/internal/services/smtp.go index c477953..7bdb391 100644 --- a/backend/internal/services/smtp.go +++ b/backend/internal/services/smtp.go @@ -17,10 +17,21 @@ package services -import "go.uber.org/zap" +import ( + "crypto/tls" + "easywish/config" + "fmt" + "net" + "net/smtp" + "strings" + "time" + + "go.uber.org/zap" + errs "easywish/internal/errors" +) type SmtpService interface { - SendEmail(to string, content string) + SendEmail(to string, subject, body string) error } type smtpServiceImpl struct { @@ -31,7 +42,102 @@ func NewSmtpService(_log *zap.Logger) SmtpService { return &smtpServiceImpl{log: _log} } -func (s *smtpServiceImpl) SendEmail(to string, content string) { - panic("unimplemented") -} +func (s *smtpServiceImpl) SendEmail(to string, subject, body string) error { + cfg := config.GetConfig() + if !cfg.SmtpEnabled { + s.log.Error("Attempted to send an email with SMTP disabled in the config") + return errs.ErrSmtpDisabled + } + + if cfg.SmtpServer == "" || cfg.SmtpPort == 0 || cfg.SmtpFrom == "" { + s.log.Error("SMTP service settings or the SMTP From paramater are not set") + return errs.ErrSmtpMissingConfiguration + } + + toSlice := []string{to} + + headers := map[string]string{ + "From": cfg.SmtpFrom, + "To": strings.Join(toSlice, ", "), + "Subject": subject, + "MIME-Version": "1.0", + "Content-Type": "text/html; charset=UTF-8", + } + + var sb strings.Builder + for k, v := range headers { + sb.WriteString(fmt.Sprintf("%s: %s\r\n", k, v)) + } + sb.WriteString("\r\n" + body) + message := []byte(sb.String()) + + hostPort := fmt.Sprintf("%s:%d", cfg.SmtpServer, cfg.SmtpPort) + var conn net.Conn + var err error + + if cfg.SmtpUseSSL { + tlsConfig := &tls.Config{ServerName: cfg.SmtpServer} + conn, err = tls.Dial("tcp", hostPort, tlsConfig) + } else { + timeout := time.Duration(cfg.SmtpTimeout) * time.Second + conn, err = net.DialTimeout("tcp", hostPort, timeout) + } + if err != nil { + s.log.Error("SMTP connection failure", zap.Error(err)) + return err + } + defer conn.Close() + + client, err := smtp.NewClient(conn, cfg.SmtpServer) + if err != nil { + s.log.Error("SMTP client creation failed", zap.Error(err)) + return err + } + defer client.Close() + + if !cfg.SmtpUseSSL && cfg.SmtpUseTLS { + tlsConfig := &tls.Config{ServerName: cfg.SmtpServer} + if err = client.StartTLS(tlsConfig); err != nil { + return err + } + } + + // Authenticate if credentials exist + if cfg.SmtpUser != "" && cfg.SmtpPassword != "" { + auth := smtp.PlainAuth("", cfg.SmtpUser, cfg.SmtpPassword, cfg.SmtpServer) + if err = client.Auth(auth); err != nil { + s.log.Error("SMTP authentication failure", zap.Error(err)) + return err + } + } + + if err = client.Mail(cfg.SmtpFrom); err != nil { + s.log.Error("SMTP sender set failed", zap.Error(err)) + return err + } + + for _, recipient := range to { + if err = client.Rcpt(string(recipient)); err != nil { + s.log.Error("SMTP recipient set failed", zap.Error(err)) + return err + } + } + + // Send email body + w, err := client.Data() + if err != nil { + s.log.Error("SMTP data command failed", zap.Error(err)) + return err + } + if _, err = w.Write(message); err != nil { + s.log.Error("SMTP message write failed", zap.Error(err)) + return err + } + if err = w.Close(); err != nil { + s.log.Error("SMTP message close failed", zap.Error(err)) + return err + } + + return client.Quit() +} diff --git a/docker-compose.yml b/docker-compose.yml index 0b78ac7..c388cde 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,10 +16,34 @@ services: timeout: 10s retries: 3 environment: + ENVIRONMENT: ${ENVIRONMENT} + HOSTNAME: ${HOSTNAME} + PORT: ${PORT} POSTGRES_URL: ${POSTGRES_URL} REDIS_URL: ${REDIS_URL} MINIO_URL: ${MINIO_URL} - ENVIRONMENT: ${ENVIRONMENT} + JWT_ALGORITHM: ${JWT_ALGORITHM} + JWT_SECRET: ${JWT_SECRET} + JWT_ISSUER: ${JWT_ISSUER} + JWT_AUDIENCE: ${JWT_AUDIENCE} + JWT_EXP_ACCESS: ${JWT_EXP_ACCESS} + JWT_EXP_REFRESH: ${JWT_EXP_REFRESH} + SMTP_ENABLED: ${SMTP_ENABLED} + SMTP_SERVER: ${SMTP_SERVER} + SMTP_PORT: ${SMTP_PORT} + SMTP_USER: ${SMTP_USER} + SMTP_PASSWORD: ${SMTP_PASSWORD} + SMTP_FROM: ${SMTP_FROM} + SMTP_USE_TLS: ${SMTP_USE_TLS} + SMTP_USE_SSL: ${SMTP_USE_SSL} + SMTP_TIMEOUT: ${SMTP_TIMEOUT} + PASSWORD_MIN_LENGTH: ${PASSWORD_MIN_LENGTH} + PASSWORD_MAX_LENGTH: ${PASSWORD_MAX_LENGTH} + PASSWORD_CHECK_NUMBER: ${PASSWORD_CHECK_NUMBER} + PASSWORD_CHECK_CHARAC: ${PASSWORD_CHECK_CHARAC} + PASSWORD_CHECK_CASES: ${PASSWORD_CHECK_CASES} + PASSWORD_CHECK_SYMBOL: ${PASSWORD_CHECK_SYMBOL} + PASSWORD_CHECK_LEAKED: ${PASSWORD_CHECK_LEAKED} ports: - "8080:8080" networks: