mirror of
https://github.com/ghostersk/gowebmail.git
synced 2026-04-17 08:36:01 +01:00
fixed Gmail authentication, hotmail still in progress
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -5,4 +5,5 @@ data/*db-wal
|
|||||||
data/gowebmail.conf
|
data/gowebmail.conf
|
||||||
data/*.txt
|
data/*.txt
|
||||||
gowebmail-devplan.md
|
gowebmail-devplan.md
|
||||||
testrun/
|
testrun/
|
||||||
|
webmail.code-workspace
|
||||||
@@ -83,7 +83,7 @@ func main() {
|
|||||||
log.Fatalf("migrations: %v", err)
|
log.Fatalf("migrations: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
sc := syncer.New(database)
|
sc := syncer.New(database, cfg)
|
||||||
sc.Start()
|
sc.Start()
|
||||||
defer sc.Stop()
|
defer sc.Stop()
|
||||||
|
|
||||||
@@ -107,6 +107,15 @@ func main() {
|
|||||||
r.PathPrefix("/static/").Handler(
|
r.PathPrefix("/static/").Handler(
|
||||||
http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))),
|
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) {
|
r.HandleFunc("/favicon.ico", func(w http.ResponseWriter, r *http.Request) {
|
||||||
data, err := gowebmail.WebFS.ReadFile("web/static/img/favicon.png")
|
data, err := gowebmail.WebFS.ReadFile("web/static/img/favicon.png")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -130,3 +130,29 @@ func RefreshToken(ctx context.Context, cfg *oauth2.Config, refreshToken string)
|
|||||||
ts := cfg.TokenSource(ctx, &oauth2.Token{RefreshToken: refreshToken})
|
ts := cfg.TokenSource(ctx, &oauth2.Token{RefreshToken: refreshToken})
|
||||||
return ts.Token()
|
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"
|
"time"
|
||||||
|
|
||||||
"github.com/ghostersk/gowebmail/config"
|
"github.com/ghostersk/gowebmail/config"
|
||||||
|
"github.com/ghostersk/gowebmail/internal/auth"
|
||||||
"github.com/ghostersk/gowebmail/internal/db"
|
"github.com/ghostersk/gowebmail/internal/db"
|
||||||
"github.com/ghostersk/gowebmail/internal/email"
|
"github.com/ghostersk/gowebmail/internal/email"
|
||||||
"github.com/ghostersk/gowebmail/internal/middleware"
|
"github.com/ghostersk/gowebmail/internal/middleware"
|
||||||
@@ -60,9 +61,12 @@ type safeAccount struct {
|
|||||||
IMAPPort int `json:"imap_port,omitempty"`
|
IMAPPort int `json:"imap_port,omitempty"`
|
||||||
SMTPHost string `json:"smtp_host,omitempty"`
|
SMTPHost string `json:"smtp_host,omitempty"`
|
||||||
SMTPPort int `json:"smtp_port,omitempty"`
|
SMTPPort int `json:"smtp_port,omitempty"`
|
||||||
|
SyncDays int `json:"sync_days"`
|
||||||
|
SyncMode string `json:"sync_mode"`
|
||||||
LastError string `json:"last_error,omitempty"`
|
LastError string `json:"last_error,omitempty"`
|
||||||
Color string `json:"color"`
|
Color string `json:"color"`
|
||||||
LastSync string `json:"last_sync"`
|
LastSync string `json:"last_sync"`
|
||||||
|
TokenExpired bool `json:"token_expired,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func toSafeAccount(a *models.EmailAccount) safeAccount {
|
func toSafeAccount(a *models.EmailAccount) safeAccount {
|
||||||
@@ -70,11 +74,17 @@ func toSafeAccount(a *models.EmailAccount) safeAccount {
|
|||||||
if !a.LastSync.IsZero() {
|
if !a.LastSync.IsZero() {
|
||||||
lastSync = a.LastSync.Format("2006-01-02T15:04:05Z")
|
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{
|
return safeAccount{
|
||||||
ID: a.ID, Provider: a.Provider, EmailAddress: a.EmailAddress,
|
ID: a.ID, Provider: a.Provider, EmailAddress: a.EmailAddress,
|
||||||
DisplayName: a.DisplayName, IMAPHost: a.IMAPHost, IMAPPort: a.IMAPPort,
|
DisplayName: a.DisplayName, IMAPHost: a.IMAPHost, IMAPPort: a.IMAPPort,
|
||||||
SMTPHost: a.SMTPHost, SMTPPort: a.SMTPPort,
|
SMTPHost: a.SMTPHost, SMTPPort: a.SMTPPort,
|
||||||
|
SyncDays: a.SyncDays, SyncMode: a.SyncMode,
|
||||||
LastError: a.LastError, Color: a.Color, LastSync: lastSync,
|
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 {
|
if err := email.SendMessageFull(context.Background(), account, &req); err != nil {
|
||||||
log.Printf("SMTP send failed account=%d user=%d: %v", req.AccountID, userID, err)
|
log.Printf("SMTP send failed account=%d user=%d: %v", req.AccountID, userID, err)
|
||||||
h.db.WriteAudit(&userID, models.AuditAppError,
|
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})
|
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)
|
fmt.Fprintf(w, `{"error":%q}`, message)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Decide back-button destination: if the user has a session cookie they're
|
// Back-button destination: always send to "/" which RequireAuth will
|
||||||
// likely logged in, so send them home. Otherwise send to login.
|
// transparently forward to /auth/login if the session is absent or invalid.
|
||||||
backHref := "/auth/login"
|
// This avoids a stale-cookie loop where cookie presence ≠ valid session.
|
||||||
backLabel := "← Back to Login"
|
backHref := "/"
|
||||||
if _, err := r.Cookie("gomail_session"); err == nil {
|
backLabel := "← Go Back"
|
||||||
backHref = "/"
|
|
||||||
backLabel = "← Go to Home"
|
|
||||||
}
|
|
||||||
|
|
||||||
data := struct {
|
data := struct {
|
||||||
Status int
|
Status int
|
||||||
Title string
|
Title string
|
||||||
Message string
|
Message string
|
||||||
BackHref string
|
BackHref string
|
||||||
BackLabel string
|
BackLabel string
|
||||||
}{status, title, message, backHref, backLabel}
|
}{status, title, message, backHref, backLabel}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/ghostersk/gowebmail/config"
|
||||||
|
"github.com/ghostersk/gowebmail/internal/auth"
|
||||||
"github.com/ghostersk/gowebmail/internal/db"
|
"github.com/ghostersk/gowebmail/internal/db"
|
||||||
"github.com/ghostersk/gowebmail/internal/email"
|
"github.com/ghostersk/gowebmail/internal/email"
|
||||||
"github.com/ghostersk/gowebmail/internal/models"
|
"github.com/ghostersk/gowebmail/internal/models"
|
||||||
@@ -20,6 +22,7 @@ import (
|
|||||||
// Scheduler coordinates all background sync activity.
|
// Scheduler coordinates all background sync activity.
|
||||||
type Scheduler struct {
|
type Scheduler struct {
|
||||||
db *db.DB
|
db *db.DB
|
||||||
|
cfg *config.Config
|
||||||
stop chan struct{}
|
stop chan struct{}
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
|
|
||||||
@@ -29,9 +32,10 @@ type Scheduler struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new Scheduler.
|
// New creates a new Scheduler.
|
||||||
func New(database *db.DB) *Scheduler {
|
func New(database *db.DB, cfg *config.Config) *Scheduler {
|
||||||
return &Scheduler{
|
return &Scheduler{
|
||||||
db: database,
|
db: database,
|
||||||
|
cfg: cfg,
|
||||||
stop: make(chan struct{}),
|
stop: make(chan struct{}),
|
||||||
pushCh: make(map[int64]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)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
account = s.ensureFreshToken(account)
|
||||||
c, err := email.Connect(ctx, account)
|
c, err := email.Connect(ctx, account)
|
||||||
cancel()
|
cancel()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -338,6 +343,7 @@ func (s *Scheduler) deltaSync(account *models.EmailAccount) {
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
account = s.ensureFreshToken(account)
|
||||||
c, err := email.Connect(ctx, account)
|
c, err := email.Connect(ctx, account)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[sync:%s] connect: %v", account.EmailAddress, err)
|
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)
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
account = s.ensureFreshToken(account)
|
||||||
c, err := email.Connect(ctx, account)
|
c, err := email.Connect(ctx, account)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
@@ -496,6 +503,7 @@ func (s *Scheduler) drainPendingOps(account *models.EmailAccount) {
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
account = s.ensureFreshToken(account)
|
||||||
c, err := email.Connect(ctx, account)
|
c, err := email.Connect(ctx, account)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[ops:%s] connect for drain: %v", account.EmailAddress, err)
|
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) ----
|
// ---- Public API (called by HTTP handlers) ----
|
||||||
|
|
||||||
// SyncAccountNow performs an immediate delta sync of one account.
|
// 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)
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
account = s.ensureFreshToken(account)
|
||||||
c, err := email.Connect(ctx, account)
|
c, err := email.Connect(ctx, account)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
|
|||||||
@@ -79,12 +79,16 @@ function renderAccountsPopup() {
|
|||||||
el.innerHTML = '<div style="font-size:12px;color:var(--muted);padding:8px 0">No accounts connected.</div>';
|
el.innerHTML = '<div style="font-size:12px;color:var(--muted);padding:8px 0">No accounts connected.</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
el.innerHTML = S.accounts.map(a => `
|
el.innerHTML = S.accounts.map(a => {
|
||||||
<div class="acct-popup-item" title="${esc(a.email_address)}${a.last_error?' ⚠ '+esc(a.last_error):''}">
|
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 `
|
||||||
|
<div class="acct-popup-item" title="${esc(a.email_address)}${hasWarning?' — '+warningTitle:''}">
|
||||||
<div style="display:flex;align-items:center;gap:8px;flex:1;min-width:0">
|
<div style="display:flex;align-items:center;gap:8px;flex:1;min-width:0">
|
||||||
<span class="account-dot" style="background:${a.color};flex-shrink:0"></span>
|
<span class="account-dot" style="background:${a.color};flex-shrink:0"></span>
|
||||||
<span style="font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(a.display_name||a.email_address)}</span>
|
<span style="font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(a.display_name||a.email_address)}</span>
|
||||||
${a.last_error?'<span style="color:var(--danger);font-size:11px">⚠</span>':''}
|
${a.token_expired?'<span style="color:var(--danger);font-size:11px" title="OAuth token expired">🔑</span>':
|
||||||
|
a.last_error?'<span style="color:var(--danger);font-size:11px">⚠</span>':''}
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;gap:4px;flex-shrink:0">
|
<div style="display:flex;gap:4px;flex-shrink:0">
|
||||||
<button class="icon-btn" title="Sync now" onclick="syncNow(${a.id},event)">
|
<button class="icon-btn" title="Sync now" onclick="syncNow(${a.id},event)">
|
||||||
@@ -97,7 +101,8 @@ function renderAccountsPopup() {
|
|||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>`).join('');
|
</div>`;
|
||||||
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Accounts ───────────────────────────────────────────────────────────────
|
// ── Accounts ───────────────────────────────────────────────────────────────
|
||||||
@@ -184,13 +189,38 @@ async function openEditAccount(id) {
|
|||||||
document.getElementById('edit-account-id').value=id;
|
document.getElementById('edit-account-id').value=id;
|
||||||
document.getElementById('edit-account-email').textContent=r.email_address;
|
document.getElementById('edit-account-email').textContent=r.email_address;
|
||||||
document.getElementById('edit-name').value=r.display_name||'';
|
document.getElementById('edit-name').value=r.display_name||'';
|
||||||
document.getElementById('edit-password').value='';
|
|
||||||
document.getElementById('edit-imap-host').value=r.imap_host||'';
|
const isOAuth = r.provider==='gmail' || r.provider==='outlook';
|
||||||
document.getElementById('edit-imap-port').value=r.imap_port||993;
|
|
||||||
document.getElementById('edit-smtp-host').value=r.smtp_host||'';
|
// Show/hide credential section and test button based on provider type
|
||||||
document.getElementById('edit-smtp-port').value=r.smtp_port||587;
|
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;
|
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');
|
const sel = document.getElementById('edit-sync-mode');
|
||||||
if (r.sync_mode==='all' || !r.sync_days) {
|
if (r.sync_mode==='all' || !r.sync_days) {
|
||||||
sel.value='all';
|
sel.value='all';
|
||||||
@@ -199,12 +229,12 @@ async function openEditAccount(id) {
|
|||||||
sel.value = presetMap[r.sync_days] || 'days';
|
sel.value = presetMap[r.sync_days] || 'days';
|
||||||
}
|
}
|
||||||
toggleSyncDaysField();
|
toggleSyncDaysField();
|
||||||
|
|
||||||
const errEl=document.getElementById('edit-last-error'), connEl=document.getElementById('edit-conn-result');
|
const errEl=document.getElementById('edit-last-error'), connEl=document.getElementById('edit-conn-result');
|
||||||
connEl.style.display='none';
|
connEl.style.display='none';
|
||||||
errEl.style.display=r.last_error?'block':'none';
|
errEl.style.display=r.last_error?'block':'none';
|
||||||
if (r.last_error) errEl.textContent='Last sync error: '+r.last_error;
|
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 hiddenEl = document.getElementById('edit-hidden-folders');
|
||||||
const hidden = S.folders.filter(f=>f.account_id===id && f.is_hidden);
|
const hidden = S.folders.filter(f=>f.account_id===id && f.is_hidden);
|
||||||
if (!hidden.length) {
|
if (!hidden.length) {
|
||||||
@@ -249,6 +279,10 @@ function toggleSyncDaysField() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function testEditConnection() {
|
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 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();
|
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;}
|
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() {
|
async function saveAccountEdit() {
|
||||||
const id=document.getElementById('edit-account-id').value;
|
const id=document.getElementById('edit-account-id').value;
|
||||||
const body={display_name:document.getElementById('edit-name').value.trim(),
|
const isOAuth = document.getElementById('edit-creds-section').style.display === 'none';
|
||||||
imap_host:document.getElementById('edit-imap-host').value.trim(),imap_port:parseInt(document.getElementById('edit-imap-port').value)||993,
|
const body={display_name:document.getElementById('edit-name').value.trim()};
|
||||||
smtp_host:document.getElementById('edit-smtp-host').value.trim(),smtp_port:parseInt(document.getElementById('edit-smtp-port').value)||587};
|
if (!isOAuth) {
|
||||||
const pw=document.getElementById('edit-password').value;
|
body.imap_host=document.getElementById('edit-imap-host').value.trim();
|
||||||
if (pw) body.password=pw;
|
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;
|
const modeVal = document.getElementById('edit-sync-mode').value;
|
||||||
let syncMode='all', syncDays=0;
|
let syncMode='all', syncDays=0;
|
||||||
if (modeVal==='days') {
|
if (modeVal==='days') {
|
||||||
|
|||||||
@@ -39,5 +39,5 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{define "scripts"}}
|
{{define "scripts"}}
|
||||||
<script src="/static/js/admin.js?v=23"></script>
|
<script src="/static/js/admin.js?v=24"></script>
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -224,15 +224,31 @@
|
|||||||
<p id="edit-account-email" style="font-weight:500;color:var(--text);margin-bottom:16px"></p>
|
<p id="edit-account-email" style="font-weight:500;color:var(--text);margin-bottom:16px"></p>
|
||||||
<input type="hidden" id="edit-account-id">
|
<input type="hidden" id="edit-account-id">
|
||||||
<div class="modal-field"><label>Display Name</label><input type="text" id="edit-name"></div>
|
<div class="modal-field"><label>Display Name</label><input type="text" id="edit-name"></div>
|
||||||
<div class="modal-field"><label>New Password (leave blank to keep current)</label><input type="password" id="edit-password"></div>
|
|
||||||
<div class="modal-row">
|
<!-- OAuth reconnect — shown only for gmail/outlook accounts -->
|
||||||
<div class="modal-field"><label>IMAP Host</label><input type="text" id="edit-imap-host"></div>
|
<div id="edit-oauth-section" style="display:none">
|
||||||
<div class="modal-field"><label>IMAP Port</label><input type="number" id="edit-imap-port"></div>
|
<div id="edit-oauth-expired-warning" style="display:none;background:rgba(239,68,68,.12);border:1px solid rgba(239,68,68,.35);border-radius:8px;padding:10px 14px;margin-bottom:10px;font-size:13px;color:#f87171">
|
||||||
|
⚠️ Access token has expired — sync and send will fail until you reconnect.
|
||||||
|
</div>
|
||||||
|
<div style="background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:14px 16px;margin-bottom:4px">
|
||||||
|
<div style="font-size:13px;color:var(--muted);margin-bottom:10px">This account connects via <strong id="edit-oauth-provider-label"></strong> OAuth. To update permissions or fix an expired token, reconnect below.</div>
|
||||||
|
<button class="btn-secondary" id="edit-oauth-reconnect-btn" style="width:100%">🔗 Reconnect with <span id="edit-oauth-provider-label-btn"></span></button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-row">
|
|
||||||
<div class="modal-field"><label>SMTP Host</label><input type="text" id="edit-smtp-host"></div>
|
<!-- IMAP/SMTP credentials (hidden for OAuth accounts) -->
|
||||||
<div class="modal-field"><label>SMTP Port</label><input type="number" id="edit-smtp-port"></div>
|
<div id="edit-creds-section">
|
||||||
|
<div class="modal-field"><label>New Password (leave blank to keep current)</label><input type="password" id="edit-password"></div>
|
||||||
|
<div class="modal-row">
|
||||||
|
<div class="modal-field"><label>IMAP Host</label><input type="text" id="edit-imap-host"></div>
|
||||||
|
<div class="modal-field"><label>IMAP Port</label><input type="number" id="edit-imap-port"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-row">
|
||||||
|
<div class="modal-field"><label>SMTP Host</label><input type="text" id="edit-smtp-host"></div>
|
||||||
|
<div class="modal-field"><label>SMTP Port</label><input type="number" id="edit-smtp-port"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-group-title" style="margin:16px 0 8px">Sync Settings</div>
|
<div class="settings-group-title" style="margin:16px 0 8px">Sync Settings</div>
|
||||||
<div class="modal-field">
|
<div class="modal-field">
|
||||||
<label>Email history to sync</label>
|
<label>Email history to sync</label>
|
||||||
@@ -352,5 +368,5 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{define "scripts"}}
|
{{define "scripts"}}
|
||||||
<script src="/static/js/app.js?v=23"></script>
|
<script src="/static/js/app.js?v=24"></script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -5,12 +5,12 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{{block "title" .}}GoWebMail{{end}}</title>
|
<title>{{block "title" .}}GoWebMail{{end}}</title>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=DM+Sans:ital,wght@0,300;0,400;0,500;1,400&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=DM+Sans:ital,wght@0,300;0,400;0,500;1,400&display=swap" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="/static/css/gowebmail.css?v=23">
|
<link rel="stylesheet" href="/static/css/gowebmail.css?v=24">
|
||||||
{{block "head_extra" .}}{{end}}
|
{{block "head_extra" .}}{{end}}
|
||||||
</head>
|
</head>
|
||||||
<body class="{{block "body_class" .}}{{end}}">
|
<body class="{{block "body_class" .}}{{end}}">
|
||||||
{{block "body" .}}{{end}}
|
{{block "body" .}}{{end}}
|
||||||
<script src="/static/js/gowebmail.js?v=23"></script>
|
<script src="/static/js/gowebmail.js?v=24"></script>
|
||||||
{{block "scripts" .}}{{end}}
|
{{block "scripts" .}}{{end}}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user