From 742fae8b9528c52a11ad07f672846a2707d9cadc Mon Sep 17 00:00:00 2001 From: cclohmar Date: Thu, 23 Apr 2026 11:33:53 +0000 Subject: [PATCH] Phase 4: Settings & IMAP connection flow fixes (Version: 2026-04.4) - Fix settings password bug: only update when non-empty, remove form 'required' - Fix dashboard stats: correct TotalProcessed template path, add setup banner - Implement TestConnectionHandler with real go-imap connect+login - Implement ProcessNowHandler: signals worker for immediate processing - Add Worker.ProcessNow() with buffered signal channel - Pass worker reference to Handler for process-now coordination - Restructure main.go: create worker before handlers, start after wiring --- docs/CHANGELOG.md | 36 +++++++++---- src/cmd/main.go | 40 ++++++++------- src/internal/web/handlers.go | 87 ++++++++++++++++++++++++++++---- src/internal/worker/worker.go | 14 +++++ src/web/templates/dashboard.html | 10 +++- src/web/templates/settings.html | 4 +- 6 files changed, 148 insertions(+), 43 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 13231ad..0e6a310 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -5,6 +5,28 @@ All notable changes to inBOXER will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2026-04.4] - 2026-04-23 + +### Fixed +- **Settings page unusable**: Password field referenced nonexistent struct field (`IMAPPass` vs `IMAPPassEncrypted`), rendered blank on every page load +- **Password wiped on save**: Leaving password field blank (because it showed empty) overwrote encrypted password with empty string; now only updates when user enters a new password +- **"Test Connection" never worked**: `TestConnectionHandler` returned `"Not implemented yet"`; now performs actual IMAP connect+login using go-imap and returns success/failure JSON +- **"Process Now" was a no-op**: `ProcessNowHandler` just redirected; now signals worker to run an immediate processing cycle via new `Worker.ProcessNow()` channel +- **Dashboard showed 0 for emails processed**: Template referenced `{{ .Stats.TotalProcessed }}` but `TotalProcessed` is a separate struct field, not a map key; corrected to `{{ .TotalProcessed }}` +- **No getting-started guidance**: Dashboard now shows an info banner on first visit directing users to configure their IMAP account in Settings +- **Password field `required`**: Removed HTML5 `required` attribute so users can save other settings without re-entering their password + +### Changed +- `Handler` struct now holds a `*worker.Worker` reference for `ProcessNowHandler` +- Worker initialization moved before handler creation in `main.go` to satisfy the dependency +- `Handler.NewHandler()` signature extended with `bgWorker *worker.Worker` parameter +- Settings POST handler re-reads decrypted settings after save so the form reflects the current state + +### Added +- `Worker.ProcessNow()` — sends signal to `processNow` channel (buffered, capacity 1) to trigger `processAllUsers()` outside the normal poll interval +- `Worker.processNow` channel field (buffered, prevents goroutine block) +- `encoding/json` import in handlers for `TestConnectionHandler` JSON responses + ## [2026-04.3] - 2026-04-23 ### Added @@ -22,8 +44,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Main orchestrator now initializes DeepSeek classifier and passes to worker - Worker uses real AI classifier when available; falls back to placeholder on init failure -### Fixed -- N/A +## [2026-04.2] - 2026-04-23 ### Added - IMAP client package (`src/internal/imap/`): @@ -48,8 +69,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Worker now creates target folders automatically on connect - Email processing respects per-user poll interval and batch size -### Fixed -- N/A (initial release) +## [2026-04.1] - 2026-04-23 ### Added - Initial repository structure per `PROJECT_PLAN.md` @@ -88,10 +108,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Authentication middleware for protected routes - Static file serving - Makefile with build, run, test targets -- Unit tests for authentication package - -### Changed -- N/A (initial release) - -### Fixed -- N/A (initial release) \ No newline at end of file +- Unit tests for authentication package \ No newline at end of file diff --git a/src/cmd/main.go b/src/cmd/main.go index a8c3930..84b0164 100644 --- a/src/cmd/main.go +++ b/src/cmd/main.go @@ -68,8 +68,26 @@ func main() { // Initialize auth service authService := auth.NewAuthService(smtpSender, sessionManager, otpStore) - // Initialize web handlers - handler, err := web.NewHandler(authService, database, cfg) + // Initialize AI classifier + deepSeekAPI := ai.NewDeepSeekAPI( + env.DeepSeekAPIKey, + cfg.AI.Model, + cfg.AI.MaxTokens, + cfg.AI.Temperature, + ) + var classifier worker.AIClassifier + classifier, err = ai.NewClassifier(deepSeekAPI, cfg.AI.PromptFile) + if err != nil { + log.Printf("Warning: AI classifier initialization failed: %v", err) + log.Println("Falling back to placeholder classifier (all emails -> Other)") + classifier = worker.NewPlaceholderClassifier(cfg.Folders.Other) + } + + // Create background worker (not started yet — handlers need a reference first) + bgWorker := worker.NewWorker(database, cfg, classifier) + + // Initialize web handlers (needs worker for ProcessNow) + handler, err := web.NewHandler(authService, database, cfg, bgWorker) if err != nil { log.Fatalf("Failed to initialize web handlers: %v", err) } @@ -91,23 +109,7 @@ func main() { IdleTimeout: 60 * time.Second, } - // Initialize AI classifier - deepSeekAPI := ai.NewDeepSeekAPI( - env.DeepSeekAPIKey, - cfg.AI.Model, - cfg.AI.MaxTokens, - cfg.AI.Temperature, - ) - var classifier worker.AIClassifier - classifier, err = ai.NewClassifier(deepSeekAPI, cfg.AI.PromptFile) - if err != nil { - log.Printf("Warning: AI classifier initialization failed: %v", err) - log.Println("Falling back to placeholder classifier (all emails -> Other)") - classifier = worker.NewPlaceholderClassifier(cfg.Folders.Other) - } - - // Start background worker - bgWorker := worker.NewWorker(database, cfg, classifier) + // Start background worker (now that everything is wired up) bgWorker.Start() // Start server in goroutine diff --git a/src/internal/web/handlers.go b/src/internal/web/handlers.go index f52a3d6..2e32ff9 100644 --- a/src/internal/web/handlers.go +++ b/src/internal/web/handlers.go @@ -1,6 +1,7 @@ package web import ( + "encoding/json" "fmt" "html/template" "net/http" @@ -9,6 +10,8 @@ import ( "inboxer/src/internal/auth" "inboxer/src/internal/db" + "inboxer/src/internal/imap" + "inboxer/src/internal/worker" "inboxer/src/pkg/config" "github.com/gorilla/mux" ) @@ -19,10 +22,11 @@ type Handler struct { db *db.Database config *config.Config templates *template.Template + worker *worker.Worker } // NewHandler creates a new handler with dependencies -func NewHandler(authService *auth.AuthService, database *db.Database, cfg *config.Config) (*Handler, error) { +func NewHandler(authService *auth.AuthService, database *db.Database, cfg *config.Config, bgWorker *worker.Worker) (*Handler, error) { // Parse templates templates, err := parseTemplates() if err != nil { @@ -34,6 +38,7 @@ func NewHandler(authService *auth.AuthService, database *db.Database, cfg *confi db: database, config: cfg, templates: templates, + worker: bgWorker, }, nil } @@ -300,7 +305,13 @@ func (h *Handler) SettingsHandler(w http.ResponseWriter, r *http.Request) { settings.IMAPHost = r.FormValue("imap_host") settings.IMAPPort = parseInt(r.FormValue("imap_port"), 993) settings.IMAPUser = r.FormValue("imap_user") - settings.IMAPPassEncrypted = r.FormValue("imap_pass") + + // Only update password if the user entered a new one + // (password field is left blank to keep existing password) + if pass := r.FormValue("imap_pass"); pass != "" { + settings.IMAPPassEncrypted = pass + } + settings.IMAPTLS = r.FormValue("imap_tls") == "on" settings.BatchSize = parseInt(r.FormValue("batch_size"), 10) settings.PollInterval = parseInt(r.FormValue("poll_interval"), 5) @@ -317,6 +328,11 @@ func (h *Handler) SettingsHandler(w http.ResponseWriter, r *http.Request) { data.Error = fmt.Sprintf("Failed to save settings: %v", err) } else { data.Success = "Settings saved successfully!" + // Re-read settings so the template gets fresh (decrypted) values for next edit + settings, err = h.db.GetMailboxSettings(user.ID) + if err != nil { + // If re-read fails, keep current in-memory settings + } } } @@ -377,19 +393,70 @@ func (h *Handler) ProcessNowHandler(w http.ResponseWriter, r *http.Request) { return } - // TODO: Trigger email processing - // For now, just redirect with success message - // In future, this will trigger the worker to process emails immediately - - // Set flash message (would need flash session implementation) + // Signal the worker to process all users immediately + h.worker.ProcessNow() + + // Redirect back to dashboard http.Redirect(w, r, "/dashboard", http.StatusSeeOther) } // TestConnectionHandler tests IMAP connection with provided settings func (h *Handler) TestConnectionHandler(w http.ResponseWriter, r *http.Request) { - // TODO: Implement IMAP connection test w.Header().Set("Content-Type", "application/json") - w.Write([]byte(`{"success": false, "error": "Not implemented yet"}`)) + enc := json.NewEncoder(w) + + // Check authentication + email, loggedIn := h.authService.GetSessionManager().GetUserEmail(r) + if !loggedIn { + enc.Encode(map[string]interface{}{"success": false, "error": "Not authenticated"}) + return + } + + if err := r.ParseForm(); err != nil { + enc.Encode(map[string]interface{}{"success": false, "error": "Invalid form data"}) + return + } + + imapHost := r.FormValue("imap_host") + imapPort := parseInt(r.FormValue("imap_port"), 993) + imapUser := r.FormValue("imap_user") + imapPass := r.FormValue("imap_pass") + imapTLS := r.FormValue("imap_tls") == "on" + + // If password is blank, try to use the stored (decrypted) password + if imapPass == "" { + user, uErr := h.db.GetUserByEmail(email) + if uErr == nil { + if settings, sErr := h.db.GetMailboxSettings(user.ID); sErr == nil { + imapPass = settings.IMAPPassEncrypted + } + } + } + + if imapHost == "" || imapUser == "" || imapPass == "" { + enc.Encode(map[string]interface{}{ + "success": false, + "error": "IMAP host, user, and password are required. Enter your password or save settings first.", + }) + return + } + + config := imap.Config{ + Host: imapHost, + Port: imapPort, + User: imapUser, + Password: imapPass, + TLS: imapTLS, + } + + testClient := imap.NewClient(config) + if err := testClient.Connect(); err != nil { + enc.Encode(map[string]interface{}{"success": false, "error": err.Error()}) + return + } + testClient.Close() + + enc.Encode(map[string]interface{}{"success": true, "error": nil}) } // renderTemplate renders a template with the given data @@ -430,5 +497,5 @@ func (h *Handler) RegisterRoutes(router *mux.Router) { router.HandleFunc("/logout", h.LogoutHandler).Methods("GET") router.HandleFunc("/toggle-test-mode", h.ToggleTestModeHandler).Methods("POST") router.HandleFunc("/test-connection", h.TestConnectionHandler).Methods("POST") - router.HandleFunc("/process-now", h.ProcessNowHandler).Methods("GET") // TODO: Implement + router.HandleFunc("/process-now", h.ProcessNowHandler).Methods("GET") } \ No newline at end of file diff --git a/src/internal/worker/worker.go b/src/internal/worker/worker.go index e4d56f8..5a75130 100644 --- a/src/internal/worker/worker.go +++ b/src/internal/worker/worker.go @@ -40,6 +40,7 @@ type Worker struct { folders FolderConfig classifier AIClassifier stopCh chan struct{} + processNow chan struct{} // signal to trigger immediate processing stopped bool mu sync.Mutex wg sync.WaitGroup @@ -59,6 +60,7 @@ func NewWorker(database *db.Database, cfg *config.Config, classifier AIClassifie }, classifier: classifier, stopCh: make(chan struct{}), + processNow: make(chan struct{}, 1), } } @@ -118,12 +120,24 @@ func (w *Worker) mainLoop() { select { case <-ticker.C: w.processAllUsers() + case <-w.processNow: + w.processAllUsers() case <-w.stopCh: return } } } +// ProcessNow triggers an immediate processing cycle for all users. +// Safe to call from any goroutine. +func (w *Worker) ProcessNow() { + select { + case w.processNow <- struct{}{}: + default: + // channel full, a cycle is already queued + } +} + // processAllUsers iterates over all users with AutoStart enabled func (w *Worker) processAllUsers() { settings, err := w.db.GetUsersWithAutoStart() diff --git a/src/web/templates/dashboard.html b/src/web/templates/dashboard.html index 03ddc3c..e181a69 100644 --- a/src/web/templates/dashboard.html +++ b/src/web/templates/dashboard.html @@ -4,10 +4,18 @@

Dashboard

Welcome back, {{ .UserEmail }}

+ + {{ if eq .TotalProcessed 0 }} +
+ Getting Started: You haven't processed any emails yet. + Go to Email Settings to configure your IMAP account, + then click "Process Now" to start classifying your inbox. +
+ {{ end }}
-
{{ .Stats.TotalProcessed }}
+
{{ .TotalProcessed }}
Emails Processed
diff --git a/src/web/templates/settings.html b/src/web/templates/settings.html index 8e7b2f0..8d2ee93 100644 --- a/src/web/templates/settings.html +++ b/src/web/templates/settings.html @@ -29,8 +29,8 @@
-

For Gmail, use an App Password. Your password is encrypted before storage.

+ placeholder="Leave blank to keep current" autocomplete="off"> +

For Gmail, use an App Password. Your password is encrypted before storage. Leave blank to keep your existing password.