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
This commit is contained in:
Claus Lohmar 2026-04-23 11:33:53 +00:00
parent 283faddb05
commit 742fae8b95
6 changed files with 148 additions and 43 deletions

View file

@ -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)
- Unit tests for authentication package

View file

@ -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

View file

@ -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")
}

View file

@ -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()

View file

@ -4,10 +4,18 @@
<h1 class="card-title">Dashboard</h1>
<p class="card-subtitle">Welcome back, {{ .UserEmail }}</p>
</div>
{{ if eq .TotalProcessed 0 }}
<div class="alert alert-info mt-3" style="background: #e0f2fe; border: 1px solid #7dd3fc; border-radius: var(--radius-md); padding: var(--spacing-md); margin-bottom: var(--spacing-md);">
<strong>Getting Started:</strong> You haven't processed any emails yet.
Go to <a href="/settings" style="color: var(--primary);">Email Settings</a> to configure your IMAP account,
then click <strong>"Process Now"</strong> to start classifying your inbox.
</div>
{{ end }}
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value">{{ .Stats.TotalProcessed }}</div>
<div class="stat-value">{{ .TotalProcessed }}</div>
<div class="stat-label">Emails Processed</div>
</div>
<div class="stat-card">

View file

@ -29,8 +29,8 @@
<div class="form-group">
<label for="imap_pass" class="form-label">Password / App Password</label>
<input type="password" id="imap_pass" name="imap_pass" class="form-input"
value="{{ .Settings.IMAPPass }}" placeholder="Your email password" required>
<p class="text-light mt-1">For Gmail, use an App Password. Your password is encrypted before storage.</p>
placeholder="Leave blank to keep current" autocomplete="off">
<p class="text-light mt-1">For Gmail, use an App Password. Your password is encrypted before storage. Leave blank to keep your existing password.</p>
</div>
<div class="form-group">