mirror of
https://github.com/ghostersk/gowebmail.git
synced 2026-06-17 07:59:38 +01:00
fixed Gmail authentication, hotmail still in progress
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user