diff --git a/.env.example b/.env.example index d6cf735..6395cb2 100644 --- a/.env.example +++ b/.env.example @@ -10,3 +10,18 @@ REFRESH_TOKEN_EXPIRY=7d CORS_ORIGINS=http://localhost:3000 DEFAULT_TIMEZONE=UTC + +# Email Configuration (optional - required for email features) +SMTP_HOST= +SMTP_PORT=587 +SMTP_USER= +SMTP_PASSWORD= + +# Application Branding (optional - override support email if needed) +SUPPORT_EMAIL=contact@apocapoc.app + +# Email Features +SEND_WELCOME_EMAIL=false + +# Registration Control +REGISTRATION_MODE=open diff --git a/cmd/api/main.go b/cmd/api/main.go index 6cf0502..738c4a5 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -13,8 +13,10 @@ import ( "apocapoc-api/internal/infrastructure/auth" "apocapoc-api/internal/infrastructure/config" "apocapoc-api/internal/infrastructure/crypto" + "apocapoc-api/internal/infrastructure/email" httpInfra "apocapoc-api/internal/infrastructure/http" "apocapoc-api/internal/infrastructure/persistence/sqlite" + "apocapoc-api/internal/shared/constants" ) // @title Apocapoc API @@ -60,12 +62,29 @@ func main() { jwtService := auth.NewJWTService(cfg.JWTSecret, jwtExpiryHours) passwordHasher := crypto.NewBcryptHasher() + var emailService *email.SMTPService + if cfg.SMTPHost != "" { + smtpPort, err := strconv.Atoi(cfg.SMTPPort) + if err != nil { + log.Fatalf("Invalid SMTP_PORT: %v", err) + } + + emailService = email.NewSMTPService(email.SMTPConfig{ + Host: cfg.SMTPHost, + Port: smtpPort, + Username: cfg.SMTPUser, + Password: cfg.SMTPPassword, + From: constants.DefaultFrom, + SupportEmail: cfg.SupportEmail, + }) + } + userRepo := sqlite.NewUserRepository(db.Conn()) habitRepo := sqlite.NewHabitRepository(db.Conn()) entryRepo := sqlite.NewHabitEntryRepository(db.Conn()) refreshTokenRepo := sqlite.NewRefreshTokenRepository(db.Conn()) - registerHandler := commands.NewRegisterUserHandler(userRepo, passwordHasher) + registerHandler := commands.NewRegisterUserHandler(userRepo, passwordHasher, emailService, constants.AppURL, cfg.RegistrationMode) loginHandler := queries.NewLoginUserHandler(userRepo, passwordHasher) refreshTokenHandler := queries.NewRefreshTokenHandler(refreshTokenRepo, userRepo) revokeTokenHandler := commands.NewRevokeTokenHandler(refreshTokenRepo) diff --git a/go.mod b/go.mod index d8082d7..f9cea45 100644 --- a/go.mod +++ b/go.mod @@ -35,6 +35,8 @@ require ( golang.org/x/net v0.47.0 // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/tools v0.36.0 // indirect + gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect + gopkg.in/mail.v2 v2.3.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect modernc.org/libc v1.66.10 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/go.sum b/go.sum index 5735573..960c17b 100644 --- a/go.sum +++ b/go.sum @@ -85,10 +85,14 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk= +gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/mail.v2 v2.3.1 h1:WYFn/oANrAGP2C0dcV6/pbkPzv8yGzqTjPmTeO7qoXk= +gopkg.in/mail.v2 v2.3.1/go.mod h1:htwXN1Qh09vZJ1NVKxQqHPBaCBbzKhp5GzuJEA4VJWw= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= diff --git a/internal/application/commands/register_user.go b/internal/application/commands/register_user.go index 41762dc..9962980 100644 --- a/internal/application/commands/register_user.go +++ b/internal/application/commands/register_user.go @@ -2,6 +2,10 @@ package commands import ( "context" + "crypto/rand" + "encoding/hex" + "fmt" + "time" "apocapoc-api/internal/domain/entities" "apocapoc-api/internal/domain/repositories" @@ -16,38 +20,119 @@ type RegisterUserCommand struct { Timezone string } -type RegisterUserHandler struct { - userRepo repositories.UserRepository - passwordHasher services.PasswordHasher +type RegisterUserResult struct { + UserID string + EmailVerificationRequired bool } -func NewRegisterUserHandler(userRepo repositories.UserRepository, passwordHasher services.PasswordHasher) *RegisterUserHandler { +type RegisterUserHandler struct { + userRepo repositories.UserRepository + passwordHasher services.PasswordHasher + emailService services.EmailService + appURL string + registrationMode string +} + +func NewRegisterUserHandler( + userRepo repositories.UserRepository, + passwordHasher services.PasswordHasher, + emailService services.EmailService, + appURL string, + registrationMode string, +) *RegisterUserHandler { return &RegisterUserHandler{ - userRepo: userRepo, - passwordHasher: passwordHasher, + userRepo: userRepo, + passwordHasher: passwordHasher, + emailService: emailService, + appURL: appURL, + registrationMode: registrationMode, } } -func (h *RegisterUserHandler) Handle(ctx context.Context, cmd RegisterUserCommand) (string, error) { +func (h *RegisterUserHandler) Handle(ctx context.Context, cmd RegisterUserCommand) (*RegisterUserResult, error) { + if h.registrationMode == "closed" { + return nil, errors.ErrRegistrationClosed + } + if err := validation.ValidateRegistration(cmd.Email, cmd.Password, cmd.Timezone); err != nil { - return "", errors.ErrInvalidInput + return nil, errors.ErrInvalidInput } existing, _ := h.userRepo.FindByEmail(ctx, cmd.Email) if existing != nil { - return "", errors.ErrAlreadyExists + return nil, errors.ErrAlreadyExists } hashedPassword, err := h.passwordHasher.Hash(cmd.Password) if err != nil { - return "", err + return nil, err } user := entities.NewUser(cmd.Email, hashedPassword, cmd.Timezone) - if err := h.userRepo.Create(ctx, user); err != nil { - return "", err + emailVerificationRequired := false + if h.emailService != nil { + token, err := h.generateVerificationToken() + if err != nil { + return nil, fmt.Errorf("failed to generate verification token: %w", err) + } + + expiry := time.Now().Add(24 * time.Hour) + user.EmailVerificationToken = &token + user.EmailVerificationExpiry = &expiry + emailVerificationRequired = true + } else { + user.EmailVerified = true } - return user.ID, nil + if err := h.userRepo.Create(ctx, user); err != nil { + return nil, err + } + + if h.emailService != nil && user.EmailVerificationToken != nil { + if err := h.sendVerificationEmail(user); err != nil { + return &RegisterUserResult{ + UserID: user.ID, + EmailVerificationRequired: emailVerificationRequired, + }, nil + } + } + + return &RegisterUserResult{ + UserID: user.ID, + EmailVerificationRequired: emailVerificationRequired, + }, nil +} + +func (h *RegisterUserHandler) generateVerificationToken() (string, error) { + bytes := make([]byte, 32) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + return hex.EncodeToString(bytes), nil +} + +func (h *RegisterUserHandler) sendVerificationEmail(user *entities.User) error { + if user.EmailVerificationToken == nil { + return nil + } + + verificationLink := fmt.Sprintf("%s/verify-email?token=%s", h.appURL, *user.EmailVerificationToken) + + emailBody := fmt.Sprintf(` +
Thank you for registering. Please click the link below to verify your email address:
+ +This link will expire in 24 hours.
+If you didn't create an account, you can safely ignore this email.
+ `, verificationLink) + + message := services.EmailMessage{ + To: user.Email, + Subject: "Verify your email address", + Body: emailBody, + IsHTML: true, + } + + return h.emailService.Send(message) } diff --git a/internal/application/commands/register_user_test.go b/internal/application/commands/register_user_test.go index dedf034..0dc6e44 100644 --- a/internal/application/commands/register_user_test.go +++ b/internal/application/commands/register_user_test.go @@ -25,6 +25,10 @@ func (m *mockUserRepo) FindByID(ctx context.Context, id string) (*entities.User, return nil, nil } +func (m *mockUserRepo) FindByVerificationToken(ctx context.Context, token string) (*entities.User, error) { + return nil, appErrors.ErrNotFound +} + func (m *mockUserRepo) Create(ctx context.Context, user *entities.User) error { if m.createFunc != nil { return m.createFunc(ctx, user) @@ -61,7 +65,7 @@ func TestRegisterUserHandler_Success(t *testing.T) { }, } hasher := &mockPasswordHasher{} - handler := NewRegisterUserHandler(repo, hasher) + handler := NewRegisterUserHandler(repo, hasher, nil, "", "open") cmd := RegisterUserCommand{ Email: "test@example.com", @@ -69,15 +73,19 @@ func TestRegisterUserHandler_Success(t *testing.T) { Timezone: "UTC", } - userID, err := handler.Handle(context.Background(), cmd) + result, err := handler.Handle(context.Background(), cmd) if err != nil { t.Fatalf("expected no error, got %v", err) } - if userID == "" { + if result.UserID == "" { t.Error("expected user ID, got empty string") } + if result.EmailVerificationRequired { + t.Error("expected email verification to not be required when emailService is nil") + } + if createdUser == nil { t.Fatal("expected user to be created") } @@ -94,7 +102,7 @@ func TestRegisterUserHandler_Success(t *testing.T) { func TestRegisterUserHandler_InvalidEmail(t *testing.T) { repo := &mockUserRepo{} hasher := &mockPasswordHasher{} - handler := NewRegisterUserHandler(repo, hasher) + handler := NewRegisterUserHandler(repo, hasher, nil, "", "open") tests := []struct { name string @@ -127,7 +135,7 @@ func TestRegisterUserHandler_InvalidEmail(t *testing.T) { func TestRegisterUserHandler_InvalidPassword(t *testing.T) { repo := &mockUserRepo{} hasher := &mockPasswordHasher{} - handler := NewRegisterUserHandler(repo, hasher) + handler := NewRegisterUserHandler(repo, hasher, nil, "", "open") tests := []struct { name string @@ -162,7 +170,7 @@ func TestRegisterUserHandler_InvalidPassword(t *testing.T) { func TestRegisterUserHandler_InvalidTimezone(t *testing.T) { repo := &mockUserRepo{} hasher := &mockPasswordHasher{} - handler := NewRegisterUserHandler(repo, hasher) + handler := NewRegisterUserHandler(repo, hasher, nil, "", "open") tests := []struct { name string @@ -198,7 +206,7 @@ func TestRegisterUserHandler_EmailAlreadyExists(t *testing.T) { }, } hasher := &mockPasswordHasher{} - handler := NewRegisterUserHandler(repo, hasher) + handler := NewRegisterUserHandler(repo, hasher, nil, "", "open") cmd := RegisterUserCommand{ Email: "test@example.com", @@ -220,7 +228,7 @@ func TestRegisterUserHandler_PasswordHashingError(t *testing.T) { return "", expectedErr }, } - handler := NewRegisterUserHandler(repo, hasher) + handler := NewRegisterUserHandler(repo, hasher, nil, "", "open") cmd := RegisterUserCommand{ Email: "test@example.com", @@ -242,7 +250,7 @@ func TestRegisterUserHandler_RepositoryError(t *testing.T) { }, } hasher := &mockPasswordHasher{} - handler := NewRegisterUserHandler(repo, hasher) + handler := NewRegisterUserHandler(repo, hasher, nil, "", "open") cmd := RegisterUserCommand{ Email: "test@example.com", @@ -259,7 +267,7 @@ func TestRegisterUserHandler_RepositoryError(t *testing.T) { func TestRegisterUserHandler_EdgeCases(t *testing.T) { repo := &mockUserRepo{} hasher := &mockPasswordHasher{} - handler := NewRegisterUserHandler(repo, hasher) + handler := NewRegisterUserHandler(repo, hasher, nil, "", "open") tests := []struct { name string @@ -322,3 +330,19 @@ func TestRegisterUserHandler_EdgeCases(t *testing.T) { }) } } +func TestRegisterUserHandler_ClosedRegistration(t *testing.T) { + repo := &mockUserRepo{} + hasher := &mockPasswordHasher{} + handler := NewRegisterUserHandler(repo, hasher, nil, "", "closed") + + cmd := RegisterUserCommand{ + Email: "test@example.com", + Password: "Secure123!", + Timezone: "UTC", + } + + _, err := handler.Handle(context.Background(), cmd) + if err != appErrors.ErrRegistrationClosed { + t.Errorf("expected ErrRegistrationClosed, got %v", err) + } +} diff --git a/internal/application/commands/resend_verification_email_handler.go b/internal/application/commands/resend_verification_email_handler.go new file mode 100644 index 0000000..20027a2 --- /dev/null +++ b/internal/application/commands/resend_verification_email_handler.go @@ -0,0 +1,95 @@ +package commands + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "time" + + "apocapoc-api/internal/domain/repositories" + "apocapoc-api/internal/domain/services" + "apocapoc-api/internal/shared/errors" +) + +type ResendVerificationEmailCommand struct { + Email string +} + +type ResendVerificationEmailHandler struct { + userRepo repositories.UserRepository + emailService services.EmailService + appURL string +} + +func NewResendVerificationEmailHandler( + userRepo repositories.UserRepository, + emailService services.EmailService, + appURL string, +) *ResendVerificationEmailHandler { + return &ResendVerificationEmailHandler{ + userRepo: userRepo, + emailService: emailService, + appURL: appURL, + } +} + +func (h *ResendVerificationEmailHandler) Handle(ctx context.Context, cmd ResendVerificationEmailCommand) error { + if cmd.Email == "" { + return errors.ErrInvalidInput + } + + user, err := h.userRepo.FindByEmail(ctx, cmd.Email) + if err != nil { + return errors.ErrNotFound + } + + if user.EmailVerified { + return errors.ErrAlreadyExists + } + + token, err := generateVerificationToken() + if err != nil { + return fmt.Errorf("failed to generate verification token: %w", err) + } + + expiry := time.Now().Add(24 * time.Hour) + user.EmailVerificationToken = &token + user.EmailVerificationExpiry = &expiry + user.UpdatedAt = time.Now() + + if err := h.userRepo.Update(ctx, user); err != nil { + return fmt.Errorf("failed to update user: %w", err) + } + + verificationLink := fmt.Sprintf("%s/verify-email?token=%s", h.appURL, token) + + emailBody := fmt.Sprintf(` +Please click the link below to verify your email address:
+ +This link will expire in 24 hours.
+If you didn't create an account, you can safely ignore this email.
+ `, verificationLink) + + message := services.EmailMessage{ + To: user.Email, + Subject: "Verify your email address", + Body: emailBody, + IsHTML: true, + } + + if err := h.emailService.Send(message); err != nil { + return fmt.Errorf("failed to send verification email: %w", err) + } + + return nil +} + +func generateVerificationToken() (string, error) { + bytes := make([]byte, 32) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + return hex.EncodeToString(bytes), nil +} diff --git a/internal/application/commands/verify_email_handler.go b/internal/application/commands/verify_email_handler.go new file mode 100644 index 0000000..326a759 --- /dev/null +++ b/internal/application/commands/verify_email_handler.go @@ -0,0 +1,54 @@ +package commands + +import ( + "context" + "fmt" + "time" + + "apocapoc-api/internal/domain/repositories" + "apocapoc-api/internal/shared/errors" +) + +type VerifyEmailCommand struct { + Token string +} + +type VerifyEmailHandler struct { + userRepo repositories.UserRepository +} + +func NewVerifyEmailHandler(userRepo repositories.UserRepository) *VerifyEmailHandler { + return &VerifyEmailHandler{ + userRepo: userRepo, + } +} + +func (h *VerifyEmailHandler) Handle(ctx context.Context, cmd VerifyEmailCommand) error { + if cmd.Token == "" { + return errors.ErrInvalidInput + } + + user, err := h.userRepo.FindByVerificationToken(ctx, cmd.Token) + if err != nil { + return errors.ErrInvalidInput + } + + if user.EmailVerified { + return errors.ErrAlreadyExists + } + + if user.EmailVerificationExpiry == nil || user.EmailVerificationExpiry.Before(time.Now()) { + return errors.ErrInvalidInput + } + + user.EmailVerified = true + user.EmailVerificationToken = nil + user.EmailVerificationExpiry = nil + user.UpdatedAt = time.Now() + + if err := h.userRepo.Update(ctx, user); err != nil { + return fmt.Errorf("failed to verify email: %w", err) + } + + return nil +} diff --git a/internal/application/queries/login_user.go b/internal/application/queries/login_user.go index 4de3b97..fc8af98 100644 --- a/internal/application/queries/login_user.go +++ b/internal/application/queries/login_user.go @@ -45,6 +45,10 @@ func (h *LoginUserHandler) Handle(ctx context.Context, query LoginUserQuery) (*L return nil, errors.ErrNotFound } + if !user.EmailVerified { + return nil, errors.ErrEmailNotVerified + } + return &LoginUserResult{ UserID: user.ID, Email: user.Email, diff --git a/internal/application/queries/refresh_token_test.go b/internal/application/queries/refresh_token_test.go index d4556af..d1c8f1e 100644 --- a/internal/application/queries/refresh_token_test.go +++ b/internal/application/queries/refresh_token_test.go @@ -59,6 +59,10 @@ func (m *mockUserRepositoryForRefresh) FindByEmail(ctx context.Context, email st return nil, nil } +func (m *mockUserRepositoryForRefresh) FindByVerificationToken(ctx context.Context, token string) (*entities.User, error) { + return nil, errors.ErrNotFound +} + func (m *mockUserRepositoryForRefresh) Update(ctx context.Context, user *entities.User) error { return nil } diff --git a/internal/domain/entities/user.go b/internal/domain/entities/user.go index fe9715d..45d1e12 100644 --- a/internal/domain/entities/user.go +++ b/internal/domain/entities/user.go @@ -3,12 +3,15 @@ package entities import "time" type User struct { - ID string - Email string - PasswordHash string - Timezone string - CreatedAt time.Time - UpdatedAt time.Time + ID string + Email string + PasswordHash string + Timezone string + EmailVerified bool + EmailVerificationToken *string + EmailVerificationExpiry *time.Time + CreatedAt time.Time + UpdatedAt time.Time } func NewUser(email, passwordHash, timezone string) *User { diff --git a/internal/domain/repositories/user_repository.go b/internal/domain/repositories/user_repository.go index da32dc3..cf8740e 100644 --- a/internal/domain/repositories/user_repository.go +++ b/internal/domain/repositories/user_repository.go @@ -10,5 +10,6 @@ type UserRepository interface { Create(ctx context.Context, user *entities.User) error FindByID(ctx context.Context, id string) (*entities.User, error) FindByEmail(ctx context.Context, email string) (*entities.User, error) + FindByVerificationToken(ctx context.Context, token string) (*entities.User, error) Update(ctx context.Context, user *entities.User) error } diff --git a/internal/domain/services/email_service.go b/internal/domain/services/email_service.go new file mode 100644 index 0000000..68568f6 --- /dev/null +++ b/internal/domain/services/email_service.go @@ -0,0 +1,12 @@ +package services + +type EmailMessage struct { + To string + Subject string + Body string + IsHTML bool +} + +type EmailService interface { + Send(message EmailMessage) error +} diff --git a/internal/infrastructure/config/config.go b/internal/infrastructure/config/config.go index a156dd0..8a955e9 100644 --- a/internal/infrastructure/config/config.go +++ b/internal/infrastructure/config/config.go @@ -16,6 +16,13 @@ type Config struct { RefreshTokenExpiry string CORSOrigins string DefaultTimezone string + SMTPHost string + SMTPPort string + SMTPUser string + SMTPPassword string + SupportEmail string + SendWelcomeEmail string + RegistrationMode string } func Load() (*Config, error) { @@ -30,6 +37,13 @@ func Load() (*Config, error) { RefreshTokenExpiry: os.Getenv("REFRESH_TOKEN_EXPIRY"), CORSOrigins: os.Getenv("CORS_ORIGINS"), DefaultTimezone: os.Getenv("DEFAULT_TIMEZONE"), + SMTPHost: os.Getenv("SMTP_HOST"), + SMTPPort: getEnvOrDefault("SMTP_PORT", "587"), + SMTPUser: os.Getenv("SMTP_USER"), + SMTPPassword: os.Getenv("SMTP_PASSWORD"), + SupportEmail: getEnvOrDefault("SUPPORT_EMAIL", "contact@apocapoc.app"), + SendWelcomeEmail: getEnvOrDefault("SEND_WELCOME_EMAIL", "false"), + RegistrationMode: getEnvOrDefault("REGISTRATION_MODE", "open"), } if cfg.DBPath == "" { diff --git a/internal/infrastructure/email/smtp_service.go b/internal/infrastructure/email/smtp_service.go new file mode 100644 index 0000000..6a54868 --- /dev/null +++ b/internal/infrastructure/email/smtp_service.go @@ -0,0 +1,76 @@ +package email + +import ( + "crypto/tls" + "fmt" + "time" + + "apocapoc-api/internal/domain/services" + + "gopkg.in/mail.v2" +) + +type SMTPConfig struct { + Host string + Port int + Username string + Password string + From string + SupportEmail string +} + +type SMTPService struct { + config SMTPConfig +} + +func NewSMTPService(config SMTPConfig) *SMTPService { + return &SMTPService{ + config: config, + } +} + +func (s *SMTPService) Send(message services.EmailMessage) error { + m := mail.NewMessage() + m.SetHeader("From", s.config.From) + m.SetHeader("To", message.To) + m.SetHeader("Subject", message.Subject) + + if message.IsHTML { + m.SetBody("text/html", message.Body) + } else { + m.SetBody("text/plain", message.Body) + } + + dialer := mail.NewDialer(s.config.Host, s.config.Port, s.config.Username, s.config.Password) + dialer.TLSConfig = &tls.Config{ + ServerName: s.config.Host, + } + + if err := s.sendWithRetry(dialer, m); err != nil { + return fmt.Errorf("failed to send email: %w", err) + } + + return nil +} + +func (s *SMTPService) sendWithRetry(dialer *mail.Dialer, message *mail.Message) error { + maxRetries := 3 + var lastErr error + + for i := 0; i < maxRetries; i++ { + if err := dialer.DialAndSend(message); err == nil { + return nil + } else { + lastErr = err + if i < maxRetries-1 { + time.Sleep(time.Second * time.Duration(i+1)) + } + } + } + + return lastErr +} + +func (s *SMTPService) GetConfig() SMTPConfig { + return s.config +} diff --git a/internal/infrastructure/email/smtp_service_test.go b/internal/infrastructure/email/smtp_service_test.go new file mode 100644 index 0000000..5ef47cc --- /dev/null +++ b/internal/infrastructure/email/smtp_service_test.go @@ -0,0 +1,64 @@ +package email + +import ( + "testing" + + "apocapoc-api/internal/domain/services" +) + +func TestNewSMTPService(t *testing.T) { + config := SMTPConfig{ + Host: "smtp.example.com", + Port: 587, + Username: "user@example.com", + Password: "password", + From: "noreply@example.com", + SupportEmail: "support@example.com", + } + + service := NewSMTPService(config) + + if service == nil { + t.Fatal("Expected service to be created") + } + + if service.GetConfig().Host != config.Host { + t.Errorf("Expected host %s, got %s", config.Host, service.GetConfig().Host) + } +} + +func TestSMTPService_MessageConstruction(t *testing.T) { + config := SMTPConfig{ + Host: "smtp.example.com", + Port: 587, + Username: "user@example.com", + Password: "password", + From: "noreply@example.com", + SupportEmail: "support@example.com", + } + + service := NewSMTPService(config) + + message := services.EmailMessage{ + To: "recipient@example.com", + Subject: "Test Email", + Body: "