diff --git a/.gitignore b/.gitignore index 3754f62..15b333f 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ data/*db-wal data/gowebmail.conf data/*.txt gowebmail-devplan.md -testrun/ \ No newline at end of file +testrun/ +webmail.code-workspace \ No newline at end of file diff --git a/cmd/server/main.go b/cmd/server/main.go index 48c7ec3..b9632ad 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -83,7 +83,7 @@ func main() { log.Fatalf("migrations: %v", err) } - sc := syncer.New(database) + sc := syncer.New(database, cfg) sc.Start() defer sc.Stop() @@ -107,6 +107,15 @@ func main() { r.PathPrefix("/static/").Handler( http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))), ) + // Legacy /app path redirect — some browsers bookmark this; redirect to root + // which RequireAuth will then forward to login if not signed in. + r.HandleFunc("/app", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/", http.StatusFound) + }).Methods("GET") + r.HandleFunc("/app/", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/", http.StatusFound) + }).Methods("GET") + r.HandleFunc("/favicon.ico", func(w http.ResponseWriter, r *http.Request) { data, err := gowebmail.WebFS.ReadFile("web/static/img/favicon.png") if err != nil { diff --git a/internal/auth/oauth.go b/internal/auth/oauth.go index d31329d..f15a9c3 100644 --- a/internal/auth/oauth.go +++ b/internal/auth/oauth.go @@ -130,3 +130,29 @@ func RefreshToken(ctx context.Context, cfg *oauth2.Config, refreshToken string) ts := cfg.TokenSource(ctx, &oauth2.Token{RefreshToken: refreshToken}) return ts.Token() } + +// RefreshAccountToken refreshes the OAuth token for a Gmail or Outlook account. +// Pass the credentials for both providers; the correct ones are selected based +// on provider ("gmail" or "outlook"). +func RefreshAccountToken(ctx context.Context, + provider, refreshToken, baseURL, + googleClientID, googleClientSecret, + msClientID, msClientSecret, msTenantID string, +) (accessToken, newRefresh string, expiry time.Time, err error) { + + var cfg *oauth2.Config + switch provider { + case "gmail": + cfg = NewGmailConfig(googleClientID, googleClientSecret, baseURL+"/auth/gmail/callback") + case "outlook": + cfg = NewOutlookConfig(msClientID, msClientSecret, msTenantID, baseURL+"/auth/outlook/callback") + default: + return "", "", time.Time{}, fmt.Errorf("not an OAuth provider: %s", provider) + } + + tok, err := RefreshToken(ctx, cfg, refreshToken) + if err != nil { + return "", "", time.Time{}, err + } + return tok.AccessToken, tok.RefreshToken, tok.Expiry, nil +} diff --git a/internal/handlers/api.go b/internal/handlers/api.go index 3dcef6d..2a99c41 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -13,6 +13,7 @@ import ( "time" "github.com/ghostersk/gowebmail/config" + "github.com/ghostersk/gowebmail/internal/auth" "github.com/ghostersk/gowebmail/internal/db" "github.com/ghostersk/gowebmail/internal/email" "github.com/ghostersk/gowebmail/internal/middleware" @@ -60,9 +61,12 @@ type safeAccount struct { IMAPPort int `json:"imap_port,omitempty"` SMTPHost string `json:"smtp_host,omitempty"` SMTPPort int `json:"smtp_port,omitempty"` + SyncDays int `json:"sync_days"` + SyncMode string `json:"sync_mode"` LastError string `json:"last_error,omitempty"` Color string `json:"color"` LastSync string `json:"last_sync"` + TokenExpired bool `json:"token_expired,omitempty"` } func toSafeAccount(a *models.EmailAccount) safeAccount { @@ -70,11 +74,17 @@ func toSafeAccount(a *models.EmailAccount) safeAccount { if !a.LastSync.IsZero() { lastSync = a.LastSync.Format("2006-01-02T15:04:05Z") } + tokenExpired := false + if (a.Provider == models.ProviderGmail || a.Provider == models.ProviderOutlook) && auth.IsTokenExpired(a.TokenExpiry) { + tokenExpired = true + } return safeAccount{ ID: a.ID, Provider: a.Provider, EmailAddress: a.EmailAddress, DisplayName: a.DisplayName, IMAPHost: a.IMAPHost, IMAPPort: a.IMAPPort, SMTPHost: a.SMTPHost, SMTPPort: a.SMTPPort, + SyncDays: a.SyncDays, SyncMode: a.SyncMode, LastError: a.LastError, Color: a.Color, LastSync: lastSync, + TokenExpired: tokenExpired, } } @@ -753,6 +763,7 @@ func (h *APIHandler) handleSend(w http.ResponseWriter, r *http.Request, mode str } } + account = h.ensureAccountTokenFresh(account) if err := email.SendMessageFull(context.Background(), account, &req); err != nil { log.Printf("SMTP send failed account=%d user=%d: %v", req.AccountID, userID, err) h.db.WriteAudit(&userID, models.AuditAppError, @@ -1333,3 +1344,44 @@ func (h *APIHandler) SaveDraft(w http.ResponseWriter, r *http.Request) { h.writeJSON(w, map[string]bool{"ok": true}) } + +// ensureAccountTokenFresh refreshes the OAuth access token for a Gmail/Outlook +// account if it is near expiry. Returns a pointer to the (possibly updated) +// account, or the original if no refresh was needed / possible. +func (h *APIHandler) ensureAccountTokenFresh(account *models.EmailAccount) *models.EmailAccount { + if account.Provider != models.ProviderGmail && account.Provider != models.ProviderOutlook { + return account + } + if !auth.IsTokenExpired(account.TokenExpiry) { + return account + } + if account.RefreshToken == "" { + log.Printf("[oauth:%s] token expired, no refresh token stored", account.EmailAddress) + return account + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + accessTok, refreshTok, expiry, err := auth.RefreshAccountToken( + ctx, + string(account.Provider), + account.RefreshToken, + h.cfg.BaseURL, + h.cfg.GoogleClientID, h.cfg.GoogleClientSecret, + h.cfg.MicrosoftClientID, h.cfg.MicrosoftClientSecret, h.cfg.MicrosoftTenantID, + ) + if err != nil { + log.Printf("[oauth:%s] token refresh failed: %v", account.EmailAddress, err) + return account + } + if err := h.db.UpdateAccountTokens(account.ID, accessTok, refreshTok, expiry); err != nil { + log.Printf("[oauth:%s] failed to persist refreshed token: %v", account.EmailAddress, err) + return account + } + refreshed, err := h.db.GetAccount(account.ID) + if err != nil || refreshed == nil { + return account + } + log.Printf("[oauth:%s] access token refreshed for send (expires %s)", account.EmailAddress, expiry.Format("2006-01-02 15:04 UTC")) + return refreshed +} diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go index 388a4e8..05070af 100644 --- a/internal/middleware/middleware.go +++ b/internal/middleware/middleware.go @@ -292,20 +292,17 @@ func renderErrorPage(w http.ResponseWriter, r *http.Request, status int, title, fmt.Fprintf(w, `{"error":%q}`, message) return } - // Decide back-button destination: if the user has a session cookie they're - // likely logged in, so send them home. Otherwise send to login. - backHref := "/auth/login" - backLabel := "← Back to Login" - if _, err := r.Cookie("gomail_session"); err == nil { - backHref = "/" - backLabel = "← Go to Home" - } + // Back-button destination: always send to "/" which RequireAuth will + // transparently forward to /auth/login if the session is absent or invalid. + // This avoids a stale-cookie loop where cookie presence ≠ valid session. + backHref := "/" + backLabel := "← Go Back" data := struct { - Status int - Title string - Message string - BackHref string + Status int + Title string + Message string + BackHref string BackLabel string }{status, title, message, backHref, backLabel} diff --git a/internal/syncer/syncer.go b/internal/syncer/syncer.go index 8a45556..81d0926 100644 --- a/internal/syncer/syncer.go +++ b/internal/syncer/syncer.go @@ -12,6 +12,8 @@ import ( "sync" "time" + "github.com/ghostersk/gowebmail/config" + "github.com/ghostersk/gowebmail/internal/auth" "github.com/ghostersk/gowebmail/internal/db" "github.com/ghostersk/gowebmail/internal/email" "github.com/ghostersk/gowebmail/internal/models" @@ -20,6 +22,7 @@ import ( // Scheduler coordinates all background sync activity. type Scheduler struct { db *db.DB + cfg *config.Config stop chan struct{} wg sync.WaitGroup @@ -29,9 +32,10 @@ type Scheduler struct { } // New creates a new Scheduler. -func New(database *db.DB) *Scheduler { +func New(database *db.DB, cfg *config.Config) *Scheduler { return &Scheduler{ db: database, + cfg: cfg, stop: make(chan struct{}), pushCh: make(map[int64]chan struct{}), } @@ -258,6 +262,7 @@ func (s *Scheduler) idleWatcher(account *models.EmailAccount, stop chan struct{} } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + account = s.ensureFreshToken(account) c, err := email.Connect(ctx, account) cancel() if err != nil { @@ -338,6 +343,7 @@ func (s *Scheduler) deltaSync(account *models.EmailAccount) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() + account = s.ensureFreshToken(account) c, err := email.Connect(ctx, account) if err != nil { log.Printf("[sync:%s] connect: %v", account.EmailAddress, err) @@ -389,6 +395,7 @@ func (s *Scheduler) syncInbox(account *models.EmailAccount) { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() + account = s.ensureFreshToken(account) c, err := email.Connect(ctx, account) if err != nil { return @@ -496,6 +503,7 @@ func (s *Scheduler) drainPendingOps(account *models.EmailAccount) { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() + account = s.ensureFreshToken(account) c, err := email.Connect(ctx, account) if err != nil { log.Printf("[ops:%s] connect for drain: %v", account.EmailAddress, err) @@ -540,6 +548,54 @@ func (s *Scheduler) drainPendingOps(account *models.EmailAccount) { } } +// ---- OAuth token refresh ---- + +// ensureFreshToken checks whether an OAuth account's access token is near +// expiry and, if so, exchanges the refresh token for a new one, persists it +// to the database, and returns a refreshed account pointer. +// For non-OAuth accounts (imap_smtp) it is a no-op. +func (s *Scheduler) ensureFreshToken(account *models.EmailAccount) *models.EmailAccount { + if account.Provider != models.ProviderGmail && account.Provider != models.ProviderOutlook { + return account + } + if !auth.IsTokenExpired(account.TokenExpiry) { + return account + } + if account.RefreshToken == "" { + log.Printf("[oauth:%s] token expired but no refresh token stored — re-authorisation required", account.EmailAddress) + return account + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + accessTok, refreshTok, expiry, err := auth.RefreshAccountToken( + ctx, + string(account.Provider), + account.RefreshToken, + s.cfg.BaseURL, + s.cfg.GoogleClientID, s.cfg.GoogleClientSecret, + s.cfg.MicrosoftClientID, s.cfg.MicrosoftClientSecret, s.cfg.MicrosoftTenantID, + ) + if err != nil { + log.Printf("[oauth:%s] token refresh failed: %v", account.EmailAddress, err) + s.db.SetAccountError(account.ID, "OAuth token refresh failed: "+err.Error()) + return account // return original; connect will fail and log the error + } + if err := s.db.UpdateAccountTokens(account.ID, accessTok, refreshTok, expiry); err != nil { + log.Printf("[oauth:%s] failed to persist refreshed token: %v", account.EmailAddress, err) + return account + } + + // Re-fetch so the caller gets the updated access token from the DB. + refreshed, fetchErr := s.db.GetAccount(account.ID) + if fetchErr != nil || refreshed == nil { + return account + } + log.Printf("[oauth:%s] access token refreshed (expires %s)", account.EmailAddress, expiry.Format("2006-01-02 15:04 UTC")) + return refreshed +} + // ---- Public API (called by HTTP handlers) ---- // SyncAccountNow performs an immediate delta sync of one account. @@ -566,6 +622,7 @@ func (s *Scheduler) SyncFolderNow(accountID, folderID int64) (int, error) { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() + account = s.ensureFreshToken(account) c, err := email.Connect(ctx, account) if err != nil { return 0, err diff --git a/web/static/js/app.js b/web/static/js/app.js index c900f56..fd8b087 100644 --- a/web/static/js/app.js +++ b/web/static/js/app.js @@ -79,12 +79,16 @@ function renderAccountsPopup() { el.innerHTML = '