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/),
|
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).
|
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
|
## [2026-04.3] - 2026-04-23
|
||||||
|
|
||||||
### Added
|
### 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
|
- Main orchestrator now initializes DeepSeek classifier and passes to worker
|
||||||
- Worker uses real AI classifier when available; falls back to placeholder on init failure
|
- Worker uses real AI classifier when available; falls back to placeholder on init failure
|
||||||
|
|
||||||
### Fixed
|
## [2026-04.2] - 2026-04-23
|
||||||
- N/A
|
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- IMAP client package (`src/internal/imap/`):
|
- 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
|
- Worker now creates target folders automatically on connect
|
||||||
- Email processing respects per-user poll interval and batch size
|
- Email processing respects per-user poll interval and batch size
|
||||||
|
|
||||||
### Fixed
|
## [2026-04.1] - 2026-04-23
|
||||||
- N/A (initial release)
|
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- Initial repository structure per `PROJECT_PLAN.md`
|
- Initial repository structure per `PROJECT_PLAN.md`
|
||||||
|
|
@ -89,9 +109,3 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
- Static file serving
|
- Static file serving
|
||||||
- Makefile with build, run, test targets
|
- Makefile with build, run, test targets
|
||||||
- Unit tests for authentication package
|
- Unit tests for authentication package
|
||||||
|
|
||||||
### Changed
|
|
||||||
- N/A (initial release)
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- N/A (initial release)
|
|
||||||
|
|
@ -68,8 +68,26 @@ func main() {
|
||||||
// Initialize auth service
|
// Initialize auth service
|
||||||
authService := auth.NewAuthService(smtpSender, sessionManager, otpStore)
|
authService := auth.NewAuthService(smtpSender, sessionManager, otpStore)
|
||||||
|
|
||||||
// Initialize web handlers
|
// Initialize AI classifier
|
||||||
handler, err := web.NewHandler(authService, database, cfg)
|
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 {
|
if err != nil {
|
||||||
log.Fatalf("Failed to initialize web handlers: %v", err)
|
log.Fatalf("Failed to initialize web handlers: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -91,23 +109,7 @@ func main() {
|
||||||
IdleTimeout: 60 * time.Second,
|
IdleTimeout: 60 * time.Second,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize AI classifier
|
// Start background worker (now that everything is wired up)
|
||||||
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)
|
|
||||||
bgWorker.Start()
|
bgWorker.Start()
|
||||||
|
|
||||||
// Start server in goroutine
|
// Start server in goroutine
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package web
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
@ -9,6 +10,8 @@ import (
|
||||||
|
|
||||||
"inboxer/src/internal/auth"
|
"inboxer/src/internal/auth"
|
||||||
"inboxer/src/internal/db"
|
"inboxer/src/internal/db"
|
||||||
|
"inboxer/src/internal/imap"
|
||||||
|
"inboxer/src/internal/worker"
|
||||||
"inboxer/src/pkg/config"
|
"inboxer/src/pkg/config"
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
)
|
)
|
||||||
|
|
@ -19,10 +22,11 @@ type Handler struct {
|
||||||
db *db.Database
|
db *db.Database
|
||||||
config *config.Config
|
config *config.Config
|
||||||
templates *template.Template
|
templates *template.Template
|
||||||
|
worker *worker.Worker
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandler creates a new handler with dependencies
|
// 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
|
// Parse templates
|
||||||
templates, err := parseTemplates()
|
templates, err := parseTemplates()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -34,6 +38,7 @@ func NewHandler(authService *auth.AuthService, database *db.Database, cfg *confi
|
||||||
db: database,
|
db: database,
|
||||||
config: cfg,
|
config: cfg,
|
||||||
templates: templates,
|
templates: templates,
|
||||||
|
worker: bgWorker,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -300,7 +305,13 @@ func (h *Handler) SettingsHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
settings.IMAPHost = r.FormValue("imap_host")
|
settings.IMAPHost = r.FormValue("imap_host")
|
||||||
settings.IMAPPort = parseInt(r.FormValue("imap_port"), 993)
|
settings.IMAPPort = parseInt(r.FormValue("imap_port"), 993)
|
||||||
settings.IMAPUser = r.FormValue("imap_user")
|
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.IMAPTLS = r.FormValue("imap_tls") == "on"
|
||||||
settings.BatchSize = parseInt(r.FormValue("batch_size"), 10)
|
settings.BatchSize = parseInt(r.FormValue("batch_size"), 10)
|
||||||
settings.PollInterval = parseInt(r.FormValue("poll_interval"), 5)
|
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)
|
data.Error = fmt.Sprintf("Failed to save settings: %v", err)
|
||||||
} else {
|
} else {
|
||||||
data.Success = "Settings saved successfully!"
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Trigger email processing
|
// Signal the worker to process all users immediately
|
||||||
// For now, just redirect with success message
|
h.worker.ProcessNow()
|
||||||
// In future, this will trigger the worker to process emails immediately
|
|
||||||
|
|
||||||
// Set flash message (would need flash session implementation)
|
// Redirect back to dashboard
|
||||||
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
|
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestConnectionHandler tests IMAP connection with provided settings
|
// TestConnectionHandler tests IMAP connection with provided settings
|
||||||
func (h *Handler) TestConnectionHandler(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) TestConnectionHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
// TODO: Implement IMAP connection test
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
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
|
// 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("/logout", h.LogoutHandler).Methods("GET")
|
||||||
router.HandleFunc("/toggle-test-mode", h.ToggleTestModeHandler).Methods("POST")
|
router.HandleFunc("/toggle-test-mode", h.ToggleTestModeHandler).Methods("POST")
|
||||||
router.HandleFunc("/test-connection", h.TestConnectionHandler).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
|
folders FolderConfig
|
||||||
classifier AIClassifier
|
classifier AIClassifier
|
||||||
stopCh chan struct{}
|
stopCh chan struct{}
|
||||||
|
processNow chan struct{} // signal to trigger immediate processing
|
||||||
stopped bool
|
stopped bool
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
|
|
@ -59,6 +60,7 @@ func NewWorker(database *db.Database, cfg *config.Config, classifier AIClassifie
|
||||||
},
|
},
|
||||||
classifier: classifier,
|
classifier: classifier,
|
||||||
stopCh: make(chan struct{}),
|
stopCh: make(chan struct{}),
|
||||||
|
processNow: make(chan struct{}, 1),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -118,12 +120,24 @@ func (w *Worker) mainLoop() {
|
||||||
select {
|
select {
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
w.processAllUsers()
|
w.processAllUsers()
|
||||||
|
case <-w.processNow:
|
||||||
|
w.processAllUsers()
|
||||||
case <-w.stopCh:
|
case <-w.stopCh:
|
||||||
return
|
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
|
// processAllUsers iterates over all users with AutoStart enabled
|
||||||
func (w *Worker) processAllUsers() {
|
func (w *Worker) processAllUsers() {
|
||||||
settings, err := w.db.GetUsersWithAutoStart()
|
settings, err := w.db.GetUsersWithAutoStart()
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,17 @@
|
||||||
<p class="card-subtitle">Welcome back, {{ .UserEmail }}</p>
|
<p class="card-subtitle">Welcome back, {{ .UserEmail }}</p>
|
||||||
</div>
|
</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="stats-grid">
|
||||||
<div class="stat-card">
|
<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 class="stat-label">Emails Processed</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
|
|
|
||||||
|
|
@ -29,8 +29,8 @@
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="imap_pass" class="form-label">Password / App Password</label>
|
<label for="imap_pass" class="form-label">Password / App Password</label>
|
||||||
<input type="password" id="imap_pass" name="imap_pass" class="form-input"
|
<input type="password" id="imap_pass" name="imap_pass" class="form-input"
|
||||||
value="{{ .Settings.IMAPPass }}" placeholder="Your email password" required>
|
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.</p>
|
<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>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue