diff --git a/cmd/api/main.go b/cmd/api/main.go index 163a529..3cc41e2 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -116,7 +116,7 @@ func main() { authHandlers := httpInfra.NewAuthHandlers(registerHandler, loginHandler, refreshTokenHandler, revokeTokenHandler, revokeAllTokensHandler, verifyEmailHandler, resendVerificationEmailHandler, requestPasswordResetHandler, resetPasswordHandler, jwtService, refreshTokenRepo, refreshTokenExpiry, translator) habitHandlers := httpInfra.NewHabitHandlers(createHandler, getTodaysHandler, getUserHabitsHandler, getHabitByIDHandler, getHabitEntriesHandler, updateHandler, archiveHandler, markHandler, unmarkHandler, translator) statsHandlers := httpInfra.NewStatsHandlers(getHabitStatsHandler, translator) - healthHandlers := httpInfra.NewHealthHandlers(db.Conn()) + healthHandlers := httpInfra.NewHealthHandlers(db.Conn(), emailService) userHandlers := httpInfra.NewUserHandlers(deleteUserHandler, translator) router := httpInfra.NewRouter(cfg.AppURL, habitHandlers, authHandlers, statsHandlers, healthHandlers, userHandlers, jwtService, translator) diff --git a/internal/application/commands/request_password_reset_test.go b/internal/application/commands/request_password_reset_test.go index 1ee9ed5..563f880 100644 --- a/internal/application/commands/request_password_reset_test.go +++ b/internal/application/commands/request_password_reset_test.go @@ -85,6 +85,10 @@ func (m *mockRequestResetEmailService) Send(message services.EmailMessage) error return nil } +func (m *mockRequestResetEmailService) HealthCheck() error { + return nil +} + func TestRequestPasswordResetHandler_Success(t *testing.T) { user := entities.NewUser("test@example.com", "hash") user.ID = "user-123" diff --git a/internal/application/commands/verify_email_test.go b/internal/application/commands/verify_email_test.go index 477b02f..f5d3008 100644 --- a/internal/application/commands/verify_email_test.go +++ b/internal/application/commands/verify_email_test.go @@ -65,6 +65,10 @@ func (m *mockEmailService) Send(message services.EmailMessage) error { return nil } +func (m *mockEmailService) HealthCheck() error { + return nil +} + func TestVerifyEmailHandler_Success(t *testing.T) { token := "valid-token" expiry := time.Now().Add(24 * time.Hour) diff --git a/internal/domain/services/email_service.go b/internal/domain/services/email_service.go index 68568f6..4deb142 100644 --- a/internal/domain/services/email_service.go +++ b/internal/domain/services/email_service.go @@ -9,4 +9,5 @@ type EmailMessage struct { type EmailService interface { Send(message EmailMessage) error + HealthCheck() error } diff --git a/internal/infrastructure/email/smtp_service.go b/internal/infrastructure/email/smtp_service.go index 497f288..4fd0257 100644 --- a/internal/infrastructure/email/smtp_service.go +++ b/internal/infrastructure/email/smtp_service.go @@ -3,6 +3,7 @@ package email import ( "crypto/tls" "fmt" + "log" "strings" "time" @@ -53,9 +54,11 @@ func (s *SMTPService) Send(message services.EmailMessage) error { } if err := s.sendWithRetry(dialer, m); err != nil { + log.Printf("[EMAIL] status=failed to=%s subject=%q error=%q", message.To, message.Subject, err.Error()) return fmt.Errorf("failed to send email: %w", err) } + log.Printf("[EMAIL] status=sent to=%s subject=%q", message.To, message.Subject) return nil } @@ -103,3 +106,28 @@ func isConfigError(err error) bool { func (s *SMTPService) GetConfig() SMTPConfig { return s.config } + +func (s *SMTPService) HealthCheck() error { + dialer := mail.NewDialer(s.config.Host, s.config.Port, s.config.Username, s.config.Password) + dialer.TLSConfig = &tls.Config{ + ServerName: s.config.Host, + } + + if s.config.Port == 465 { + dialer.SSL = true + } + + smtpCloser, err := dialer.Dial() + if err != nil { + if isAuthError(err) { + return fmt.Errorf("SMTP authentication failed: %w", err) + } + if isConfigError(err) { + return fmt.Errorf("SMTP connection failed: %w", err) + } + return fmt.Errorf("SMTP error: %w", err) + } + defer smtpCloser.Close() + + return nil +} diff --git a/internal/infrastructure/http/health_handlers.go b/internal/infrastructure/http/health_handlers.go index c40bb08..aa6b979 100644 --- a/internal/infrastructure/http/health_handlers.go +++ b/internal/infrastructure/http/health_handlers.go @@ -1,6 +1,7 @@ package http import ( + "apocapoc-api/internal/domain/services" "database/sql" "net/http" "time" @@ -9,18 +10,21 @@ import ( var startTime = time.Now() type HealthHandlers struct { - db *sql.DB + db *sql.DB + emailService services.EmailService } -func NewHealthHandlers(db *sql.DB) *HealthHandlers { +func NewHealthHandlers(db *sql.DB, emailService services.EmailService) *HealthHandlers { return &HealthHandlers{ - db: db, + db: db, + emailService: emailService, } } type HealthResponse struct { Status string `json:"status"` Database string `json:"database"` + SMTP string `json:"smtp"` Uptime string `json:"uptime"` } @@ -34,6 +38,7 @@ type HealthResponse struct { // @Router /health [get] func (h *HealthHandlers) Health(w http.ResponseWriter, r *http.Request) { dbStatus := "ok" + smtpStatus := "ok" overallStatus := "ok" statusCode := http.StatusOK @@ -43,12 +48,25 @@ func (h *HealthHandlers) Health(w http.ResponseWriter, r *http.Request) { statusCode = http.StatusServiceUnavailable } + if h.emailService != nil { + if err := h.emailService.HealthCheck(); err != nil { + smtpStatus = "error" + if overallStatus != "degraded" { + overallStatus = "degraded" + statusCode = http.StatusServiceUnavailable + } + } + } else { + smtpStatus = "disabled" + } + uptime := time.Since(startTime) uptimeStr := formatDuration(uptime) response := HealthResponse{ Status: overallStatus, Database: dbStatus, + SMTP: smtpStatus, Uptime: uptimeStr, } diff --git a/internal/infrastructure/http/integration_test.go b/internal/infrastructure/http/integration_test.go index e01b3a3..ecc285d 100644 --- a/internal/infrastructure/http/integration_test.go +++ b/internal/infrastructure/http/integration_test.go @@ -72,7 +72,7 @@ func setupTestServer(t *testing.T) *TestServer { authHandlers := NewAuthHandlers(registerHandler, loginHandler, refreshTokenHandler, revokeTokenHandler, revokeAllTokensHandler, verifyEmailHandler, resendVerificationEmailHandler, requestPasswordResetHandler, resetPasswordHandler, jwtService, refreshTokenRepo, refreshTokenExpiry, translator) habitHandlers := NewHabitHandlers(createHandler, getTodaysHandler, getUserHabitsHandler, getHabitByIDHandler, getHabitEntriesHandler, updateHandler, archiveHandler, markHandler, unmarkHandler, translator) statsHandlers := NewStatsHandlers(getHabitStatsHandler, translator) - healthHandlers := NewHealthHandlers(db) + healthHandlers := NewHealthHandlers(db, nil) userHandlers := NewUserHandlers(deleteUserHandler, translator) router := NewRouter("http://localhost:3000", habitHandlers, authHandlers, statsHandlers, healthHandlers, userHandlers, jwtService, translator)