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(` +

Welcome! Please verify your email

+

Thank you for registering. Please click the link below to verify your email address:

+

Verify Email

+

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(` +

Verify your email address

+

Please click the link below to verify your email address:

+

Verify Email

+

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: "

Test

", + IsHTML: true, + } + + if message.To == "" { + t.Error("Expected recipient to be set") + } + + if message.Subject == "" { + t.Error("Expected subject to be set") + } + + if !message.IsHTML { + t.Error("Expected message to be HTML") + } + + if service == nil { + t.Fatal("Service should not be nil") + } +} diff --git a/internal/infrastructure/email/template_renderer.go b/internal/infrastructure/email/template_renderer.go new file mode 100644 index 0000000..cffd324 --- /dev/null +++ b/internal/infrastructure/email/template_renderer.go @@ -0,0 +1,49 @@ +package email + +import ( + "bytes" + "fmt" + "html/template" +) + +type TemplateData struct { + AppName string + AppURL string + SupportEmail string + Data map[string]interface{} +} + +type TemplateRenderer struct { + appName string + appURL string + supportEmail string +} + +func NewTemplateRenderer(appName, appURL, supportEmail string) *TemplateRenderer { + return &TemplateRenderer{ + appName: appName, + appURL: appURL, + supportEmail: supportEmail, + } +} + +func (r *TemplateRenderer) Render(templateContent string, data map[string]interface{}) (string, error) { + tmpl, err := template.New("email").Parse(templateContent) + if err != nil { + return "", fmt.Errorf("failed to parse template: %w", err) + } + + templateData := TemplateData{ + AppName: r.appName, + AppURL: r.appURL, + SupportEmail: r.supportEmail, + Data: data, + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, templateData); err != nil { + return "", fmt.Errorf("failed to execute template: %w", err) + } + + return buf.String(), nil +} diff --git a/internal/infrastructure/email/template_renderer_test.go b/internal/infrastructure/email/template_renderer_test.go new file mode 100644 index 0000000..31a9b2a --- /dev/null +++ b/internal/infrastructure/email/template_renderer_test.go @@ -0,0 +1,97 @@ +package email + +import ( + "strings" + "testing" +) + +func TestNewTemplateRenderer(t *testing.T) { + renderer := NewTemplateRenderer("Test App", "https://example.com", "support@example.com") + + if renderer == nil { + t.Fatal("Expected renderer to be created") + } + + if renderer.appName != "Test App" { + t.Errorf("Expected app name 'Test App', got '%s'", renderer.appName) + } +} + +func TestTemplateRenderer_Render(t *testing.T) { + renderer := NewTemplateRenderer("Test App", "https://example.com", "support@example.com") + + template := `Hello {{.Data.Name}}, welcome to {{.AppName}}!` + data := map[string]interface{}{ + "Name": "John", + } + + result, err := renderer.Render(template, data) + if err != nil { + t.Fatalf("Failed to render template: %v", err) + } + + expected := "Hello John, welcome to Test App!" + if result != expected { + t.Errorf("Expected '%s', got '%s'", expected, result) + } +} + +func TestTemplateRenderer_RenderWithAllVariables(t *testing.T) { + renderer := NewTemplateRenderer("My App", "https://myapp.com", "help@myapp.com") + + template := ` +App: {{.AppName}} +URL: {{.AppURL}} +Support: {{.SupportEmail}} +User: {{.Data.User}} +` + data := map[string]interface{}{ + "User": "Alice", + } + + result, err := renderer.Render(template, data) + if err != nil { + t.Fatalf("Failed to render template: %v", err) + } + + if !strings.Contains(result, "My App") { + t.Error("Expected result to contain app name") + } + if !strings.Contains(result, "https://myapp.com") { + t.Error("Expected result to contain app URL") + } + if !strings.Contains(result, "help@myapp.com") { + t.Error("Expected result to contain support email") + } + if !strings.Contains(result, "Alice") { + t.Error("Expected result to contain user name") + } +} + +func TestTemplateRenderer_RenderInvalidTemplate(t *testing.T) { + renderer := NewTemplateRenderer("Test App", "https://example.com", "support@example.com") + + template := `{{.Data.Invalid}}` + data := map[string]interface{}{} + + result, err := renderer.Render(template, data) + if err != nil { + t.Fatalf("Template should render even with missing data: %v", err) + } + + if result != "" { + t.Logf("Got result: %s", result) + } +} + +func TestTemplateRenderer_RenderSyntaxError(t *testing.T) { + renderer := NewTemplateRenderer("Test App", "https://example.com", "support@example.com") + + template := `{{.Data.Name` + data := map[string]interface{}{} + + _, err := renderer.Render(template, data) + if err == nil { + t.Error("Expected error for invalid template syntax") + } +} diff --git a/internal/infrastructure/email/templates/base.html b/internal/infrastructure/email/templates/base.html new file mode 100644 index 0000000..caefadb --- /dev/null +++ b/internal/infrastructure/email/templates/base.html @@ -0,0 +1,77 @@ + + + + + + + + +
+
+

{{.AppName}}

+
+
+ {{.Content}} +
+ +
+ + diff --git a/internal/infrastructure/http/auth_handlers.go b/internal/infrastructure/http/auth_handlers.go index 5b0c937..908d2a5 100644 --- a/internal/infrastructure/http/auth_handlers.go +++ b/internal/infrastructure/http/auth_handlers.go @@ -62,6 +62,11 @@ type AuthResponse struct { UserID string `json:"user_id"` } +type RegisterResponse struct { + UserID string `json:"user_id"` + Message string `json:"message"` +} + type RefreshRequest struct { RefreshToken string `json:"refresh_token"` } @@ -72,13 +77,14 @@ type LogoutRequest struct { // Register godoc // @Summary Register a new user -// @Description Create a new user account and receive both access token and refresh token. Store both tokens securely - the refresh token is used to obtain new access tokens when they expire. +// @Description Create a new user account. If email verification is enabled, you will receive a verification email. Otherwise, you can login immediately. // @Tags auth // @Accept json // @Produce json // @Param request body RegisterRequest true "Registration data (password requires: min 8 chars, uppercase, lowercase, digit, special char)" -// @Success 201 {object} AuthResponse "Returns access token, refresh token, and user ID" +// @Success 201 {object} RegisterResponse "Returns user ID and message about next steps" // @Failure 400 {object} ErrorResponse "Invalid input: email format, password requirements, or timezone" +// @Failure 403 {object} ErrorResponse "Registration is closed" // @Failure 409 {object} ErrorResponse "Email already registered" // @Failure 500 {object} ErrorResponse "Internal server error" // @Router /auth/register [post] @@ -95,7 +101,7 @@ func (h *AuthHandlers) Register(w http.ResponseWriter, r *http.Request) { Timezone: req.Timezone, } - userID, err := h.registerHandler.Handle(r.Context(), cmd) + result, err := h.registerHandler.Handle(r.Context(), cmd) if err != nil { if err == errors.ErrInvalidInput { respondError(w, http.StatusBadRequest, "Invalid email or password (min 8 characters)") @@ -105,31 +111,24 @@ func (h *AuthHandlers) Register(w http.ResponseWriter, r *http.Request) { respondError(w, http.StatusConflict, "Email already registered") return } + if err == errors.ErrRegistrationClosed { + respondError(w, http.StatusForbidden, "Registration is currently closed") + return + } respondError(w, http.StatusInternalServerError, "Failed to register user") return } - token, err := h.jwtService.GenerateToken(userID, req.Email) - if err != nil { - respondError(w, http.StatusInternalServerError, "Failed to generate token") - return + var message string + if result.EmailVerificationRequired { + message = "Registration successful. Please check your email to verify your account." + } else { + message = "Registration successful. You can now login." } - refreshToken, err := queries.CreateRefreshToken(userID, h.refreshTokenExpiry) - if err != nil { - respondError(w, http.StatusInternalServerError, "Failed to create refresh token") - return - } - - if err := h.refreshTokenRepo.Create(r.Context(), refreshToken); err != nil { - respondError(w, http.StatusInternalServerError, "Failed to save refresh token") - return - } - - respondJSON(w, http.StatusCreated, AuthResponse{ - Token: token, - RefreshToken: refreshToken.Token, - UserID: userID, + respondJSON(w, http.StatusCreated, RegisterResponse{ + UserID: result.UserID, + Message: message, }) } @@ -143,6 +142,7 @@ func (h *AuthHandlers) Register(w http.ResponseWriter, r *http.Request) { // @Success 200 {object} AuthResponse "Returns access token, refresh token, and user ID" // @Failure 400 {object} ErrorResponse "Invalid request body" // @Failure 401 {object} ErrorResponse "Invalid email or password" +// @Failure 403 {object} ErrorResponse "Email not verified" // @Failure 500 {object} ErrorResponse "Internal server error" // @Router /auth/login [post] func (h *AuthHandlers) Login(w http.ResponseWriter, r *http.Request) { @@ -163,6 +163,10 @@ func (h *AuthHandlers) Login(w http.ResponseWriter, r *http.Request) { respondError(w, http.StatusUnauthorized, "Invalid email or password") return } + if err == errors.ErrEmailNotVerified { + respondError(w, http.StatusForbidden, "Please verify your email before logging in") + return + } respondError(w, http.StatusInternalServerError, "Failed to login") return } diff --git a/internal/infrastructure/http/auth_integration_test.go b/internal/infrastructure/http/auth_integration_test.go index 688178d..4848ef5 100644 --- a/internal/infrastructure/http/auth_integration_test.go +++ b/internal/infrastructure/http/auth_integration_test.go @@ -22,15 +22,15 @@ func TestAuthFlow(t *testing.T) { t.Errorf("Expected status 201, got %d. Body: %s", rr.Code, rr.Body.String()) } - var resp AuthResponse + var resp RegisterResponse decodeResponse(t, rr, &resp) - if resp.Token == "" { - t.Error("Expected token in response") - } if resp.UserID == "" { t.Error("Expected user ID in response") } + if resp.Message == "" { + t.Error("Expected message in response") + } }) t.Run("Register duplicate email", func(t *testing.T) { diff --git a/internal/infrastructure/http/habit_entries_integration_test.go b/internal/infrastructure/http/habit_entries_integration_test.go index b377742..5761e52 100644 --- a/internal/infrastructure/http/habit_entries_integration_test.go +++ b/internal/infrastructure/http/habit_entries_integration_test.go @@ -10,22 +10,14 @@ func TestHabitEntriesFlow(t *testing.T) { ts := setupTestServer(t) defer ts.Close() - registerBody := RegisterRequest{ - Email: "entryuser@example.com", - Password: "Password123!", - Timezone: "UTC", - } - rr := makeRequest(t, *ts.Router, "POST", "/api/v1/auth/register", registerBody, "") - var authResp AuthResponse - decodeResponse(t, rr, &authResp) - token := authResp.Token + token := registerAndLogin(t, *ts.Router, "entryuser@example.com", "Password123!") habitBody := CreateHabitRequest{ Name: "Reading", Type: "BOOLEAN", Frequency: "DAILY", } - rr = makeRequest(t, *ts.Router, "POST", "/api/v1/habits", habitBody, token) + rr := makeRequest(t, *ts.Router, "POST", "/api/v1/habits", habitBody, token) var habitResp map[string]string decodeResponse(t, rr, &habitResp) habitID := habitResp["id"] diff --git a/internal/infrastructure/http/habit_integration_test.go b/internal/infrastructure/http/habit_integration_test.go index 86fef53..95590c1 100644 --- a/internal/infrastructure/http/habit_integration_test.go +++ b/internal/infrastructure/http/habit_integration_test.go @@ -9,15 +9,7 @@ func TestHabitCRUDFlow(t *testing.T) { ts := setupTestServer(t) defer ts.Close() - registerBody := RegisterRequest{ - Email: "habituser@example.com", - Password: "Password123!", - Timezone: "UTC", - } - rr := makeRequest(t, *ts.Router, "POST", "/api/v1/auth/register", registerBody, "") - var authResp AuthResponse - decodeResponse(t, rr, &authResp) - token := authResp.Token + token := registerAndLogin(t, *ts.Router, "habituser@example.com", "Password123!") var habitID string @@ -131,30 +123,22 @@ func TestHabitCRUDFlow(t *testing.T) { }) t.Run("Access other user's habit", func(t *testing.T) { - registerBody := RegisterRequest{ - Email: "otheruser@example.com", - Password: "Password123!", - Timezone: "UTC", - } - rr := makeRequest(t, *ts.Router, "POST", "/api/v1/auth/register", registerBody, "") - var authResp AuthResponse - decodeResponse(t, rr, &authResp) - otherToken := authResp.Token + otherToken := registerAndLogin(t, *ts.Router, "otheruser@example.com", "Password123!") reqBody := CreateHabitRequest{ Name: "Other User Habit", Type: "BOOLEAN", Frequency: "DAILY", } - rr = makeRequest(t, *ts.Router, "POST", "/api/v1/habits", reqBody, otherToken) + rr := makeRequest(t, *ts.Router, "POST", "/api/v1/habits", reqBody, otherToken) var createResp map[string]string decodeResponse(t, rr, &createResp) otherHabitID := createResp["id"] - rr = makeRequest(t, *ts.Router, "GET", "/api/v1/habits/"+otherHabitID, nil, token) + rr2 := makeRequest(t, *ts.Router, "GET", "/api/v1/habits/"+otherHabitID, nil, token) - if rr.Code != http.StatusForbidden { - t.Errorf("Expected status 403, got %d", rr.Code) + if rr2.Code != http.StatusForbidden { + t.Errorf("Expected status 403, got %d", rr2.Code) } }) } diff --git a/internal/infrastructure/http/integration_test.go b/internal/infrastructure/http/integration_test.go index f98ec0d..7f9d30f 100644 --- a/internal/infrastructure/http/integration_test.go +++ b/internal/infrastructure/http/integration_test.go @@ -41,7 +41,7 @@ func setupTestServer(t *testing.T) *TestServer { entryRepo := sqlite.NewHabitEntryRepository(db) refreshTokenRepo := sqlite.NewRefreshTokenRepository(db) - registerHandler := commands.NewRegisterUserHandler(userRepo, passwordHasher) + registerHandler := commands.NewRegisterUserHandler(userRepo, passwordHasher, nil, "", "open") loginHandler := queries.NewLoginUserHandler(userRepo, passwordHasher) refreshTokenHandler := queries.NewRefreshTokenHandler(refreshTokenRepo, userRepo) revokeTokenHandler := commands.NewRevokeTokenHandler(refreshTokenRepo) @@ -104,3 +104,22 @@ func decodeResponse(t *testing.T, rr *httptest.ResponseRecorder, target interfac t.Fatalf("Failed to decode response: %v", err) } } + +func registerAndLogin(t *testing.T, router http.Handler, email, password string) string { + registerBody := RegisterRequest{ + Email: email, + Password: password, + Timezone: "UTC", + } + makeRequest(t, router, "POST", "/api/v1/auth/register", registerBody, "") + + loginBody := LoginRequest{ + Email: email, + Password: password, + } + rr := makeRequest(t, router, "POST", "/api/v1/auth/login", loginBody, "") + + var authResp AuthResponse + decodeResponse(t, rr, &authResp) + return authResp.Token +} diff --git a/internal/infrastructure/persistence/sqlite/migrations.go b/internal/infrastructure/persistence/sqlite/migrations.go index b309815..862c382 100644 --- a/internal/infrastructure/persistence/sqlite/migrations.go +++ b/internal/infrastructure/persistence/sqlite/migrations.go @@ -2,6 +2,7 @@ package sqlite import ( "database/sql" + "fmt" ) func RunMigrations(db *sql.DB) error { @@ -18,9 +19,50 @@ func RunMigrations(db *sql.DB) error { return err } } + + if err := addEmailVerificationColumns(db); err != nil { + return err + } + return nil } +func addEmailVerificationColumns(db *sql.DB) error { + columns := []struct { + name string + definition string + }{ + {"email_verified", "ALTER TABLE users ADD COLUMN email_verified BOOLEAN DEFAULT 0"}, + {"email_verification_token", "ALTER TABLE users ADD COLUMN email_verification_token TEXT"}, + {"email_verification_expiry", "ALTER TABLE users ADD COLUMN email_verification_expiry DATETIME"}, + } + + for _, col := range columns { + exists, err := columnExists(db, "users", col.name) + if err != nil { + return err + } + + if !exists { + if _, err := db.Exec(col.definition); err != nil { + return err + } + } + } + + return nil +} + +func columnExists(db *sql.DB, table, column string) (bool, error) { + query := fmt.Sprintf("SELECT COUNT(*) FROM pragma_table_info('%s') WHERE name = ?", table) + var count int + err := db.QueryRow(query, column).Scan(&count) + if err != nil { + return false, err + } + return count > 0, nil +} + const createUsersTable = ` CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, diff --git a/internal/infrastructure/persistence/sqlite/user_repository.go b/internal/infrastructure/persistence/sqlite/user_repository.go index abee9bf..e102969 100644 --- a/internal/infrastructure/persistence/sqlite/user_repository.go +++ b/internal/infrastructure/persistence/sqlite/user_repository.go @@ -24,8 +24,8 @@ func (r *UserRepository) Create(ctx context.Context, user *entities.User) error user.ID = uuid.New().String() query := ` - INSERT INTO users (id, email, password_hash, timezone, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?) + INSERT INTO users (id, email, password_hash, timezone, email_verified, email_verification_token, email_verification_expiry, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ` _, err := r.db.ExecContext(ctx, query, @@ -33,6 +33,9 @@ func (r *UserRepository) Create(ctx context.Context, user *entities.User) error user.Email, user.PasswordHash, user.Timezone, + user.EmailVerified, + user.EmailVerificationToken, + user.EmailVerificationExpiry, user.CreatedAt, user.UpdatedAt, ) @@ -49,7 +52,7 @@ func (r *UserRepository) Create(ctx context.Context, user *entities.User) error func (r *UserRepository) FindByID(ctx context.Context, id string) (*entities.User, error) { query := ` - SELECT id, email, password_hash, timezone, created_at, updated_at + SELECT id, email, password_hash, timezone, email_verified, email_verification_token, email_verification_expiry, created_at, updated_at FROM users WHERE id = ? ` @@ -60,6 +63,9 @@ func (r *UserRepository) FindByID(ctx context.Context, id string) (*entities.Use &user.Email, &user.PasswordHash, &user.Timezone, + &user.EmailVerified, + &user.EmailVerificationToken, + &user.EmailVerificationExpiry, &user.CreatedAt, &user.UpdatedAt, ) @@ -76,7 +82,7 @@ func (r *UserRepository) FindByID(ctx context.Context, id string) (*entities.Use func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*entities.User, error) { query := ` - SELECT id, email, password_hash, timezone, created_at, updated_at + SELECT id, email, password_hash, timezone, email_verified, email_verification_token, email_verification_expiry, created_at, updated_at FROM users WHERE email = ? ` @@ -87,6 +93,9 @@ func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*entiti &user.Email, &user.PasswordHash, &user.Timezone, + &user.EmailVerified, + &user.EmailVerificationToken, + &user.EmailVerificationExpiry, &user.CreatedAt, &user.UpdatedAt, ) @@ -101,10 +110,40 @@ func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*entiti return &user, nil } +func (r *UserRepository) FindByVerificationToken(ctx context.Context, token string) (*entities.User, error) { + query := ` + SELECT id, email, password_hash, timezone, email_verified, email_verification_token, email_verification_expiry, created_at, updated_at + FROM users + WHERE email_verification_token = ? + ` + + var user entities.User + err := r.db.QueryRowContext(ctx, query, token).Scan( + &user.ID, + &user.Email, + &user.PasswordHash, + &user.Timezone, + &user.EmailVerified, + &user.EmailVerificationToken, + &user.EmailVerificationExpiry, + &user.CreatedAt, + &user.UpdatedAt, + ) + + if err == sql.ErrNoRows { + return nil, errors.ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("failed to find user by verification token: %w", err) + } + + return &user, nil +} + func (r *UserRepository) Update(ctx context.Context, user *entities.User) error { query := ` UPDATE users - SET email = ?, password_hash = ?, timezone = ?, updated_at = ? + SET email = ?, password_hash = ?, timezone = ?, email_verified = ?, email_verification_token = ?, email_verification_expiry = ?, updated_at = ? WHERE id = ? ` @@ -112,6 +151,9 @@ func (r *UserRepository) Update(ctx context.Context, user *entities.User) error user.Email, user.PasswordHash, user.Timezone, + user.EmailVerified, + user.EmailVerificationToken, + user.EmailVerificationExpiry, user.UpdatedAt, user.ID, ) diff --git a/internal/shared/constants/branding.go b/internal/shared/constants/branding.go new file mode 100644 index 0000000..adb2a93 --- /dev/null +++ b/internal/shared/constants/branding.go @@ -0,0 +1,7 @@ +package constants + +const ( + AppName = "Apocapoc" + AppURL = "https://apocapoc.app" + DefaultFrom = "noreply@apocapoc.app" +) diff --git a/internal/shared/errors/errors.go b/internal/shared/errors/errors.go index 08e4217..31c849b 100644 --- a/internal/shared/errors/errors.go +++ b/internal/shared/errors/errors.go @@ -3,9 +3,11 @@ package errors import "errors" var ( - ErrNotFound = errors.New("resource not found") - ErrAlreadyExists = errors.New("resource already exists") - ErrInvalidInput = errors.New("invalid input") - ErrUnauthorized = errors.New("unauthorized") - ErrInvalidCredentials = errors.New("invalid credentials") + ErrNotFound = errors.New("resource not found") + ErrAlreadyExists = errors.New("resource already exists") + ErrInvalidInput = errors.New("invalid input") + ErrUnauthorized = errors.New("unauthorized") + ErrInvalidCredentials = errors.New("invalid credentials") + ErrEmailNotVerified = errors.New("email not verified") + ErrRegistrationClosed = errors.New("registration is closed") )