// Package server wires the HTTP surface of the Email Agent. // // Phase 1 exposes liveness/readiness endpoints and a placeholder dashboard. // Login and the real HTMX screens arrive in Phase 1.2. package server import ( "context" "encoding/json" "errors" "fmt" "html/template" "log/slog" "net/http" "time" "gitea.lab.audasmedia.com.au/sam/email_agent/internal/config" "gitea.lab.audasmedia.com.au/sam/email_agent/internal/store" "gitea.lab.audasmedia.com.au/sam/email_agent/internal/version" ) // Server holds the HTTP dependencies. type Server struct { cfg *config.Config store *store.Store logger *slog.Logger http *http.Server index *template.Template } // New builds a Server. It does not start listening. func New(cfg *config.Config, st *store.Store, logger *slog.Logger) (*Server, error) { index, err := template.New("index").Parse(indexHTML) if err != nil { return nil, fmt.Errorf("parse index template: %w", err) } s := &Server{ cfg: cfg, store: st, logger: logger, index: index, } s.http = &http.Server{ Addr: cfg.Addr, Handler: s.Handler(), ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 60 * time.Second, } return s, nil } // Handler returns the root HTTP handler with middleware applied. func (s *Server) Handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /health", s.handleHealth) mux.HandleFunc("GET /readyz", s.handleReady) mux.HandleFunc("GET /{$}", s.handleIndex) return s.recoverPanic(s.logRequests(mux)) } // Run starts the HTTP server and blocks until ctx is cancelled. func (s *Server) Run(ctx context.Context) error { errCh := make(chan error, 1) go func() { s.logger.Info("http server listening", "addr", s.cfg.Addr, "env", s.cfg.Env, "db", s.store.Path(), "version", version.Version, ) if err := s.http.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { errCh <- err return } errCh <- nil }() select { case err := <-errCh: return err case <-ctx.Done(): shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() s.logger.Info("shutting down http server") return s.http.Shutdown(shutdownCtx) } } type healthResponse struct { Status string `json:"status"` Version string `json:"version"` Commit string `json:"commit"` Env string `json:"env"` UptimeSeconds int64 `json:"uptime_seconds"` Database string `json:"database"` Time string `json:"time"` } var startedAt = time.Now() // handleHealth reports liveness plus a database connectivity check. func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) defer cancel() resp := healthResponse{ Status: "ok", Version: version.Version, Commit: version.Commit, Env: s.cfg.Env, UptimeSeconds: int64(time.Since(startedAt).Seconds()), Database: "ok", Time: time.Now().UTC().Format(time.RFC3339), } code := http.StatusOK if err := s.store.Ping(ctx); err != nil { s.logger.Error("health check: database unreachable", "error", err) resp.Status = "degraded" resp.Database = "unreachable" code = http.StatusServiceUnavailable } writeJSON(w, code, resp) } // handleReady reports readiness for load balancers (same signal as health). func (s *Server) handleReady(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) defer cancel() if err := s.store.Ping(ctx); err != nil { writeJSON(w, http.StatusServiceUnavailable, map[string]string{"status": "not ready"}) return } writeJSON(w, http.StatusOK, map[string]string{"status": "ready"}) } // handleIndex renders a placeholder dashboard. The real login + inbox screens // are Phase 1.2. func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") data := struct { Version string Env string }{Version: version.Version, Env: s.cfg.Env} if err := s.index.Execute(w, data); err != nil { s.logger.Error("render index", "error", err) } } func writeJSON(w http.ResponseWriter, code int, v any) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(code) _ = json.NewEncoder(w).Encode(v) } // logRequests logs one line per request at debug level. func (s *Server) logRequests(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK} next.ServeHTTP(rec, r) s.logger.Debug("request", "method", r.Method, "path", r.URL.Path, "status", rec.status, "duration_ms", time.Since(start).Milliseconds(), "remote", r.RemoteAddr, ) }) } // recoverPanic keeps one bad request from taking down the process. func (s *Server) recoverPanic(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { defer func() { if rec := recover(); rec != nil { s.logger.Error("panic recovered", "error", rec, "path", r.URL.Path) http.Error(w, "internal server error", http.StatusInternalServerError) } }() next.ServeHTTP(w, r) }) } type statusRecorder struct { http.ResponseWriter status int } func (r *statusRecorder) WriteHeader(code int) { r.status = code r.ResponseWriter.WriteHeader(code) } const indexHTML = ` Email Agent

Email Agent

Phase 1 skeleton is running. Version {{.Version}} ({{.Env}}).

Login and the inbox arrive in Phase 1.2. Liveness is at /health.

`