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:
parent
283faddb05
commit
742fae8b95
6 changed files with 148 additions and 43 deletions
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Reference in a new issue