From 015c00251b83021efb09cc06b6a2ae3b11fe6609 Mon Sep 17 00:00:00 2001 From: ghostersk Date: Sun, 15 Mar 2026 09:04:40 +0000 Subject: [PATCH] fixed Gmail authentication, hotmail still in progress --- .gitignore | 3 +- cmd/server/main.go | 11 ++++- internal/auth/oauth.go | 26 +++++++++++ internal/handlers/api.go | 52 ++++++++++++++++++++++ internal/middleware/middleware.go | 21 ++++----- internal/syncer/syncer.go | 59 ++++++++++++++++++++++++- web/static/js/app.js | 71 ++++++++++++++++++++++++------- web/templates/admin.html | 2 +- web/templates/app.html | 32 ++++++++++---- web/templates/base.html | 4 +- 10 files changed, 239 insertions(+), 42 deletions(-) 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 = '
No accounts connected.
'; return; } - el.innerHTML = S.accounts.map(a => ` -
+ el.innerHTML = S.accounts.map(a => { + const hasWarning = a.last_error || a.token_expired; + const warningTitle = a.token_expired ? 'OAuth token expired — click Settings to reconnect' : (a.last_error ? '⚠ '+a.last_error : ''); + return ` +
${esc(a.display_name||a.email_address)} - ${a.last_error?'':''} + ${a.token_expired?'🔑': + a.last_error?'':''}
-
`).join(''); +
`; + }).join(''); } // ── Accounts ─────────────────────────────────────────────────────────────── @@ -184,13 +189,38 @@ async function openEditAccount(id) { document.getElementById('edit-account-id').value=id; document.getElementById('edit-account-email').textContent=r.email_address; document.getElementById('edit-name').value=r.display_name||''; - document.getElementById('edit-password').value=''; - document.getElementById('edit-imap-host').value=r.imap_host||''; - document.getElementById('edit-imap-port').value=r.imap_port||993; - document.getElementById('edit-smtp-host').value=r.smtp_host||''; - document.getElementById('edit-smtp-port').value=r.smtp_port||587; + + const isOAuth = r.provider==='gmail' || r.provider==='outlook'; + + // Show/hide credential section and test button based on provider type + document.getElementById('edit-creds-section').style.display = isOAuth ? 'none' : ''; + document.getElementById('edit-test-btn').style.display = isOAuth ? 'none' : ''; + const oauthSection = document.getElementById('edit-oauth-section'); + if (oauthSection) oauthSection.style.display = isOAuth ? '' : 'none'; + if (isOAuth) { + const providerLabel = r.provider==='gmail' ? 'Google' : 'Microsoft'; + const lbl = document.getElementById('edit-oauth-provider-label'); + const lblBtn = document.getElementById('edit-oauth-provider-label-btn'); + const expWarn = document.getElementById('edit-oauth-expired-warning'); + if (lbl) lbl.textContent = providerLabel; + if (lblBtn) lblBtn.textContent = providerLabel; + if (expWarn) expWarn.style.display = r.token_expired ? '' : 'none'; + const reconnectBtn = document.getElementById('edit-oauth-reconnect-btn'); + if (reconnectBtn) reconnectBtn.onclick = () => { + closeModal('edit-account-modal'); + connectOAuth(r.provider); + }; + } + + if (!isOAuth) { + document.getElementById('edit-password').value=''; + document.getElementById('edit-imap-host').value=r.imap_host||''; + document.getElementById('edit-imap-port').value=r.imap_port||993; + document.getElementById('edit-smtp-host').value=r.smtp_host||''; + document.getElementById('edit-smtp-port').value=r.smtp_port||587; + } + document.getElementById('edit-sync-days').value=r.sync_days||30; - // Restore sync mode select: map stored days/mode back to a preset option const sel = document.getElementById('edit-sync-mode'); if (r.sync_mode==='all' || !r.sync_days) { sel.value='all'; @@ -199,12 +229,12 @@ async function openEditAccount(id) { sel.value = presetMap[r.sync_days] || 'days'; } toggleSyncDaysField(); + const errEl=document.getElementById('edit-last-error'), connEl=document.getElementById('edit-conn-result'); connEl.style.display='none'; errEl.style.display=r.last_error?'block':'none'; if (r.last_error) errEl.textContent='Last sync error: '+r.last_error; - // Load hidden folders for this account const hiddenEl = document.getElementById('edit-hidden-folders'); const hidden = S.folders.filter(f=>f.account_id===id && f.is_hidden); if (!hidden.length) { @@ -249,6 +279,10 @@ function toggleSyncDaysField() { } async function testEditConnection() { + // Only relevant for IMAP/SMTP accounts — OAuth accounts reconnect via the button + if (document.getElementById('edit-creds-section').style.display === 'none') { + return; + } const btn=document.getElementById('edit-test-btn'), connEl=document.getElementById('edit-conn-result'); const pw=document.getElementById('edit-password').value, email=document.getElementById('edit-account-email').textContent.trim(); if (!pw){connEl.textContent='Enter new password to test.';connEl.className='test-result err';connEl.style.display='block';return;} @@ -263,11 +297,16 @@ async function testEditConnection() { async function saveAccountEdit() { const id=document.getElementById('edit-account-id').value; - const body={display_name:document.getElementById('edit-name').value.trim(), - imap_host:document.getElementById('edit-imap-host').value.trim(),imap_port:parseInt(document.getElementById('edit-imap-port').value)||993, - smtp_host:document.getElementById('edit-smtp-host').value.trim(),smtp_port:parseInt(document.getElementById('edit-smtp-port').value)||587}; - const pw=document.getElementById('edit-password').value; - if (pw) body.password=pw; + const isOAuth = document.getElementById('edit-creds-section').style.display === 'none'; + const body={display_name:document.getElementById('edit-name').value.trim()}; + if (!isOAuth) { + body.imap_host=document.getElementById('edit-imap-host').value.trim(); + body.imap_port=parseInt(document.getElementById('edit-imap-port').value)||993; + body.smtp_host=document.getElementById('edit-smtp-host').value.trim(); + body.smtp_port=parseInt(document.getElementById('edit-smtp-port').value)||587; + const pw=document.getElementById('edit-password').value; + if (pw) body.password=pw; + } const modeVal = document.getElementById('edit-sync-mode').value; let syncMode='all', syncDays=0; if (modeVal==='days') { diff --git a/web/templates/admin.html b/web/templates/admin.html index fd141a6..ffa1ae1 100644 --- a/web/templates/admin.html +++ b/web/templates/admin.html @@ -39,5 +39,5 @@ {{end}} {{define "scripts"}} - + {{end}} \ No newline at end of file diff --git a/web/templates/app.html b/web/templates/app.html index bbc5c61..6b05b25 100644 --- a/web/templates/app.html +++ b/web/templates/app.html @@ -224,15 +224,31 @@

- -