From 9e7e87d11bcb8fbbd453b809da017e7a519652c0 Mon Sep 17 00:00:00 2001 From: ghostersk Date: Sun, 15 Mar 2026 20:27:29 +0000 Subject: [PATCH] updated outlook account sync --- cmd/server/main.go | 2 + config/config.go | 4 ++ internal/auth/oauth.go | 12 +++--- internal/db/db.go | 8 ++++ internal/email/imap.go | 19 +++++----- internal/graph/graph.go | 53 +++++++++++++++++++++------ internal/handlers/api.go | 7 ++++ internal/handlers/auth.go | 9 +++-- internal/logger/logger.go | 24 ++++++++++++ internal/syncer/syncer.go | 73 ++++++++++++++++++++++++++++--------- web/static/img/outlook.png | Bin 4450 -> 0 bytes web/static/js/app.js | 22 +++++------ web/templates/app.html | 19 ++++++++-- web/templates/base.html | 4 +- 14 files changed, 191 insertions(+), 65 deletions(-) create mode 100644 internal/logger/logger.go delete mode 100644 web/static/img/outlook.png diff --git a/cmd/server/main.go b/cmd/server/main.go index 1a37f45..494e78a 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -15,6 +15,7 @@ import ( "github.com/ghostersk/gowebmail/config" "github.com/ghostersk/gowebmail/internal/db" "github.com/ghostersk/gowebmail/internal/handlers" + "github.com/ghostersk/gowebmail/internal/logger" "github.com/ghostersk/gowebmail/internal/middleware" "github.com/ghostersk/gowebmail/internal/syncer" @@ -72,6 +73,7 @@ func main() { if err != nil { log.Fatalf("config load: %v", err) } + logger.Init(cfg.Debug) database, err := db.New(cfg.DBPath, cfg.EncryptionKey) if err != nil { diff --git a/config/config.go b/config/config.go index eb426ff..dac410c 100644 --- a/config/config.go +++ b/config/config.go @@ -21,6 +21,9 @@ type Config struct { Hostname string // e.g. "mail.example.com" — used for BASE_URL and host checks BaseURL string // auto-built from Hostname + ListenPort, or overridden explicitly + // Debug + Debug bool // set DEBUG=true in config to enable verbose logging + // Security EncryptionKey []byte // 32 bytes / AES-256 SessionSecret []byte @@ -431,6 +434,7 @@ func Load() (*Config, error) { Hostname: hostname, BaseURL: baseURL, DBPath: get("DB_PATH"), + Debug: atobool(get("DEBUG"), false), EncryptionKey: encKey, SessionSecret: []byte(sessSecret), SecureCookie: atobool(get("SECURE_COOKIE"), false), diff --git a/internal/auth/oauth.go b/internal/auth/oauth.go index b952582..69a937f 100644 --- a/internal/auth/oauth.go +++ b/internal/auth/oauth.go @@ -6,12 +6,12 @@ import ( "encoding/base64" "encoding/json" "fmt" - "log" "net/http" "net/url" "strings" "time" + "github.com/ghostersk/gowebmail/internal/logger" "golang.org/x/oauth2" "golang.org/x/oauth2/google" "golang.org/x/oauth2/microsoft" @@ -21,7 +21,7 @@ import ( // GmailScopes are the OAuth2 scopes required for full Gmail access. var GmailScopes = []string{ - "https://mail.google.com/", // Full IMAP+SMTP access + "https://mail.google.com/", // Full IMAP+SMTP access "https://www.googleapis.com/auth/userinfo.email", "https://www.googleapis.com/auth/userinfo.profile", } @@ -148,7 +148,7 @@ func ExchangeForIMAPToken(ctx context.Context, clientID, clientSecret, tenantID, preview = preview[:30] + "..." } parts := strings.Count(result.AccessToken, ".") + 1 - log.Printf("[oauth:outlook:exchange] got token with %d parts: %s (scope=%s)", + logger.Debug("[oauth:outlook:exchange] got token with %d parts: %s (scope=%s)", parts, preview, params.Get("scope")) expiry := time.Now().Add(time.Duration(result.ExpiresIn) * time.Second) @@ -162,10 +162,10 @@ func ExchangeForIMAPToken(ctx context.Context, clientID, clientSecret, tenantID, // MicrosoftUserInfo holds user info extracted from the Microsoft ID token. type MicrosoftUserInfo struct { ID string `json:"id"` - DisplayName string `json:"displayName"` // Graph field - Name string `json:"name"` // ID token claim + DisplayName string `json:"displayName"` // Graph field + Name string `json:"name"` // ID token claim Mail string `json:"mail"` - EmailClaim string `json:"email"` // ID token claim + EmailClaim string `json:"email"` // ID token claim UserPrincipalName string `json:"userPrincipalName"` PreferredUsername string `json:"preferred_username"` // ID token claim } diff --git a/internal/db/db.go b/internal/db/db.go index f181083..df07fd5 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -1235,6 +1235,14 @@ func (d *DB) UpdateMessageBody(messageID int64, bodyText, bodyHTML string) { bodyTextEnc, bodyHTMLEnc, messageID) } +// GetNewestMessageDate returns the date of the most recent message in a folder. +// Returns zero time if the folder is empty. +func (d *DB) GetNewestMessageDate(folderID int64) time.Time { + var t time.Time + d.sql.QueryRow(`SELECT MAX(date) FROM messages WHERE folder_id=?`, folderID).Scan(&t) + return t +} + func (d *DB) ToggleMessageStar(messageID, userID int64) (bool, error) { var current bool err := d.sql.QueryRow(` diff --git a/internal/email/imap.go b/internal/email/imap.go index 69af6ca..32c98f5 100644 --- a/internal/email/imap.go +++ b/internal/email/imap.go @@ -19,6 +19,7 @@ import ( "strings" "time" + "github.com/ghostersk/gowebmail/internal/logger" "github.com/emersion/go-imap" "github.com/emersion/go-imap/client" @@ -64,9 +65,9 @@ func (x *xoauth2Client) Next(challenge []byte) ([]byte, error) { if len(challenge) > 0 { // Decode and log the error from Microsoft so it appears in server logs if dec, err := base64.StdEncoding.DecodeString(string(challenge)); err == nil { - log.Printf("[imap:xoauth2] server error for %s: %s", x.user, string(dec)) + logger.Debug("[imap:xoauth2] server error for %s: %s", x.user, string(dec)) } else { - log.Printf("[imap:xoauth2] server challenge for %s: %s", x.user, string(challenge)) + logger.Debug("[imap:xoauth2] server challenge for %s: %s", x.user, string(challenge)) } // Send empty response to let the server send the final error return []byte("\x01"), nil @@ -142,18 +143,18 @@ func Connect(ctx context.Context, account *gomailModels.EmailAccount) (*Client, Upn string `json:"upn"` } if json.Unmarshal(payload, &claims) == nil { - log.Printf("[imap:connect] %s aud=%v scp=%q token=%s", + logger.Debug("[imap:connect] %s aud=%v scp=%q token=%s", account.EmailAddress, claims.Aud, claims.Scp, tokenPreview) } else { - log.Printf("[imap:connect] %s raw claims: %s token=%s", + logger.Debug("[imap:connect] %s raw claims: %s token=%s", account.EmailAddress, string(payload), tokenPreview) } } else { - log.Printf("[imap:connect] %s opaque token (not JWT): %s", + logger.Debug("[imap:connect] %s opaque token (not JWT): %s", account.EmailAddress, tokenPreview) } } else { - log.Printf("[imap:connect] %s token has %d parts (not JWT): %s", + logger.Debug("[imap:connect] %s token has %d parts (not JWT): %s", account.EmailAddress, len(strings.Split(account.AccessToken, ".")), tokenPreview) } sasl := &xoauth2Client{user: account.EmailAddress, token: account.AccessToken} @@ -900,7 +901,7 @@ func SendMessageFull(ctx context.Context, account *gomailModels.EmailAccount, re rawMsg := buf.Bytes() addr := fmt.Sprintf("%s:%d", host, port) - log.Printf("[SMTP] dialing %s for account %s", addr, account.EmailAddress) + logger.Debug("[SMTP] dialing %s for account %s", addr, account.EmailAddress) var c *smtp.Client var err error @@ -941,7 +942,7 @@ func SendMessageFull(ctx context.Context, account *gomailModels.EmailAccount, re if err := authSMTP(c, account, host); err != nil { return fmt.Errorf("SMTP auth failed for %s: %w", account.EmailAddress, err) } - log.Printf("[SMTP] auth OK") + logger.Debug("[SMTP] auth OK") if err := c.Mail(account.EmailAddress); err != nil { return fmt.Errorf("SMTP MAIL FROM <%s>: %w", account.EmailAddress, err) @@ -971,7 +972,7 @@ func SendMessageFull(ctx context.Context, account *gomailModels.EmailAccount, re // DATA close is where the server accepts or rejects the message return fmt.Errorf("SMTP server rejected message: %w", err) } - log.Printf("[SMTP] message accepted by server") + logger.Debug("[SMTP] message accepted by server") _ = c.Quit() // Append to Sent folder via IMAP (best-effort, don't fail the send) diff --git a/internal/graph/graph.go b/internal/graph/graph.go index adeafd0..6c1a93a 100644 --- a/internal/graph/graph.go +++ b/internal/graph/graph.go @@ -12,6 +12,7 @@ import ( "io" "net/http" "net/url" + "regexp" "strings" "time" @@ -198,8 +199,10 @@ type messagesResp struct { func (c *Client) ListMessages(ctx context.Context, folderID string, since time.Time, maxResults int) ([]GraphMessage, error) { filter := "" if !since.IsZero() { - filter = "&$filter=receivedDateTime+gt+" + - url.QueryEscape(since.UTC().Format("2006-01-02T15:04:05Z")) + // OData filter: receivedDateTime gt 2006-01-02T15:04:05Z + // Use strings.ReplaceAll to keep colons unencoded — Graph accepts this form + dateStr := since.UTC().Format("2006-01-02T15:04:05Z") + filter = "&$filter=receivedDateTime gt " + url.PathEscape(dateStr) } top := 50 if maxResults > 0 && maxResults < top { @@ -322,25 +325,51 @@ func WellKnownToFolderType(wk string) string { // ---- Send mail ---- +// stripHTML does a minimal HTML→plain-text conversion for the text/plain fallback. +// Spam filters score HTML-only email negatively; sending both parts improves deliverability. +func stripHTML(s string) string { + s = regexp.MustCompile(`(?i)|

|||`).ReplaceAllString(s, "\n") + s = regexp.MustCompile(`<[^>]+>`).ReplaceAllString(s, "") + s = strings.NewReplacer("&", "&", "<", "<", ">", ">", """, `"`, "'", "'", " ", " ").Replace(s) + s = regexp.MustCompile(`\n{3,}`).ReplaceAllString(s, "\n\n") + return strings.TrimSpace(s) +} + // SendMail sends an email via Graph API POST /me/sendMail. +// Sets both HTML and plain-text body to improve deliverability (spam filters +// penalise HTML-only messages with no text/plain alternative). func (c *Client) SendMail(ctx context.Context, req *models.ComposeRequest) error { - contentType := "HTML" - body := req.BodyHTML - if body == "" { - contentType = "Text" - body = req.BodyText + // Build body: always provide both HTML and plain text for better deliverability + body := map[string]string{ + "contentType": "HTML", + "content": req.BodyHTML, + } + if req.BodyHTML == "" { + body["contentType"] = "Text" + body["content"] = req.BodyText + } + + // Set explicit from with display name + var fromField interface{} + if c.account.DisplayName != "" { + fromField = map[string]interface{}{ + "emailAddress": map[string]string{ + "address": c.account.EmailAddress, + "name": c.account.DisplayName, + }, + } } msg := map[string]interface{}{ - "subject": req.Subject, - "body": map[string]string{ - "contentType": contentType, - "content": body, - }, + "subject": req.Subject, + "body": body, "toRecipients": graphRecipients(req.To), "ccRecipients": graphRecipients(req.CC), "bccRecipients": graphRecipients(req.BCC), } + if fromField != nil { + msg["from"] = fromField + } if len(req.Attachments) > 0 { var atts []map[string]interface{} diff --git a/internal/handlers/api.go b/internal/handlers/api.go index c8d95fd..0d9be7c 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -844,6 +844,11 @@ func (h *APIHandler) handleSend(w http.ResponseWriter, r *http.Request, mode str h.writeError(w, http.StatusBadGateway, err.Error()) return } + // Delay slightly so Microsoft has time to save to Sent Items before we sync + go func() { + time.Sleep(3 * time.Second) + h.syncer.TriggerAccountSync(account.ID) + }() h.writeJSON(w, map[string]bool{"ok": true}) return } @@ -856,6 +861,8 @@ func (h *APIHandler) handleSend(w http.ResponseWriter, r *http.Request, mode str h.writeError(w, http.StatusBadGateway, err.Error()) return } + // Trigger immediate sync so the sent message appears in Sent Items + h.syncer.TriggerAccountSync(account.ID) h.writeJSON(w, map[string]bool{"ok": true}) } diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go index 24a2f40..cb2b0ac 100644 --- a/internal/handlers/auth.go +++ b/internal/handlers/auth.go @@ -12,6 +12,7 @@ import ( "strings" "time" + "github.com/ghostersk/gowebmail/internal/logger" "github.com/ghostersk/gowebmail/config" goauth "github.com/ghostersk/gowebmail/internal/auth" "github.com/ghostersk/gowebmail/internal/crypto" @@ -430,7 +431,7 @@ h2{color:#f87171} a{color:#6b8afd} http.Redirect(w, r, "/?error=userinfo_failed", http.StatusFound) return } - log.Printf("[oauth:outlook] auth successful for %s, getting IMAP token...", userInfo.Email()) + logger.Debug("[oauth:outlook] auth successful for %s, getting IMAP token...", userInfo.Email()) // Exchange initial token for one scoped to https://outlook.office.com // so IMAP auth succeeds (aud must be outlook.office.com not graph/live) @@ -440,10 +441,10 @@ h2{color:#f87171} a{color:#6b8afd} h.cfg.MicrosoftTenantID, token.RefreshToken, ) if err != nil { - log.Printf("[oauth:outlook] IMAP token exchange failed: %v — falling back to initial token", err) + logger.Debug("[oauth:outlook] IMAP token exchange failed: %v — falling back to initial token", err) imapToken = token } else { - log.Printf("[oauth:outlook] IMAP token obtained, aud should be https://outlook.office.com") + logger.Debug("[oauth:outlook] IMAP token obtained, aud should be https://outlook.office.com") if imapToken.RefreshToken == "" { imapToken.RefreshToken = token.RefreshToken } @@ -703,7 +704,7 @@ Mail.ReadWrite, Mail.Send, User.Read, openid, email, offline_access

// Verify it's a JWT (Graph token for personal accounts should be a JWT) tokenParts := len(strings.Split(token.AccessToken, ".")) - log.Printf("[oauth:outlook-personal] auth successful for %s, token parts: %d", + logger.Debug("[oauth:outlook-personal] auth successful for %s, token parts: %d", userInfo.Email(), tokenParts) accounts, _ := h.db.ListAccountsByUser(userID) diff --git a/internal/logger/logger.go b/internal/logger/logger.go new file mode 100644 index 0000000..a8dd71f --- /dev/null +++ b/internal/logger/logger.go @@ -0,0 +1,24 @@ +// Package logger provides a conditional debug logger controlled by config.Debug. +package logger + +import "log" + +var debugEnabled bool + +// Init sets whether debug logging is active. Call once at startup. +func Init(debug bool) { + debugEnabled = debug + if debug { + log.Println("[logger] debug logging enabled") + } +} + +// Debug logs a message only when debug mode is on. +func Debug(format string, args ...interface{}) { + if debugEnabled { + log.Printf(format, args...) + } +} + +// IsEnabled returns true if debug logging is on. +func IsEnabled() bool { return debugEnabled } diff --git a/internal/syncer/syncer.go b/internal/syncer/syncer.go index a457bb9..4edb851 100644 --- a/internal/syncer/syncer.go +++ b/internal/syncer/syncer.go @@ -13,6 +13,7 @@ import ( "sync" "time" + "github.com/ghostersk/gowebmail/internal/logger" "github.com/ghostersk/gowebmail/config" "github.com/ghostersk/gowebmail/internal/auth" "github.com/ghostersk/gowebmail/internal/db" @@ -427,7 +428,7 @@ func (s *Scheduler) deltaSync(account *models.EmailAccount) { s.db.UpdateAccountLastSync(account.ID) if totalNew > 0 { - log.Printf("[sync:%s] %d new messages", account.EmailAddress, totalNew) + logger.Debug("[sync:%s] %d new messages", account.EmailAddress, totalNew) } } @@ -453,7 +454,7 @@ func (s *Scheduler) syncInbox(account *models.EmailAccount) { return } if n > 0 { - log.Printf("[idle:%s] %d new messages in INBOX", account.EmailAddress, n) + logger.Debug("[idle:%s] %d new messages in INBOX", account.EmailAddress, n) } } @@ -536,6 +537,10 @@ func (s *Scheduler) syncFolder(c *email.Client, account *models.EmailAccount, db // Applies queued IMAP write operations (delete/move/flag) with retry logic. func (s *Scheduler) drainPendingOps(account *models.EmailAccount) { + // Graph accounts don't use the IMAP ops queue + if account.Provider == models.ProviderOutlookPersonal { + return + } ops, err := s.db.DequeuePendingOps(account.ID, 50) if err != nil || len(ops) == 0 { return @@ -608,10 +613,10 @@ func (s *Scheduler) ensureFreshToken(account *models.EmailAccount) *models.Email return account } if isOpaque { - log.Printf("[oauth:%s] opaque v1 token detected — forcing refresh to get JWT", account.EmailAddress) + logger.Debug("[oauth:%s] opaque v1 token detected — forcing refresh to get JWT", account.EmailAddress) } if account.RefreshToken == "" { - log.Printf("[oauth:%s] token expired but no refresh token stored — re-authorisation required", account.EmailAddress) + logger.Debug("[oauth:%s] token expired but no refresh token stored — re-authorisation required", account.EmailAddress) return account } @@ -627,12 +632,12 @@ func (s *Scheduler) ensureFreshToken(account *models.EmailAccount) *models.Email s.cfg.MicrosoftClientID, s.cfg.MicrosoftClientSecret, s.cfg.MicrosoftTenantID, ) if err != nil { - log.Printf("[oauth:%s] token refresh failed: %v", account.EmailAddress, err) + logger.Debug("[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) + logger.Debug("[oauth:%s] failed to persist refreshed token: %v", account.EmailAddress, err) return account } @@ -641,7 +646,7 @@ func (s *Scheduler) ensureFreshToken(account *models.EmailAccount) *models.Email 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")) + logger.Debug("[oauth:%s] access token refreshed (expires %s)", account.EmailAddress, expiry.Format("2006-01-02 15:04 UTC")) return refreshed } @@ -669,6 +674,42 @@ func (s *Scheduler) SyncFolderNow(accountID, folderID int64) (int, error) { return 0, fmt.Errorf("folder %d not found", folderID) } + // Graph accounts use the Graph sync path, not IMAP + if account.Provider == models.ProviderOutlookPersonal { + account = s.ensureFreshToken(account) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + gc := graph.New(account) + // Force full resync of this folder by ignoring the since filter + msgs, err := gc.ListMessages(ctx, folder.FullPath, time.Time{}, 100) + if err != nil { + return 0, fmt.Errorf("graph list messages: %w", err) + } + n := 0 + for _, gm := range msgs { + msg := &models.Message{ + AccountID: account.ID, + FolderID: folder.ID, + RemoteUID: gm.ID, + MessageID: gm.InternetMessageID, + Subject: gm.Subject, + FromName: gm.FromName(), + FromEmail: gm.FromEmail(), + ToList: gm.ToList(), + Date: gm.ReceivedDateTime, + IsRead: gm.IsRead, + IsStarred: gm.IsFlagged(), + HasAttachment: gm.HasAttachments, + } + if dbErr := s.db.UpsertMessage(msg); dbErr == nil { + n++ + } + } + // Update folder counts + s.db.UpdateFolderCountsDirect(folder.ID, len(msgs), 0) + return n, nil + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() account = s.ensureFreshToken(account) @@ -686,7 +727,7 @@ func (s *Scheduler) SyncFolderNow(accountID, folderID int64) (int, error) { // graphWorker is the accountWorker equivalent for ProviderOutlookPersonal accounts. // It polls Graph API instead of using IMAP. func (s *Scheduler) graphWorker(account *models.EmailAccount, stop chan struct{}, push chan struct{}) { - log.Printf("[graph] worker started for %s", account.EmailAddress) + logger.Debug("[graph] worker started for %s", account.EmailAddress) getAccount := func() *models.EmailAccount { a, _ := s.db.GetAccount(account.ID) @@ -705,7 +746,7 @@ func (s *Scheduler) graphWorker(account *models.EmailAccount, stop chan struct{} for { select { case <-stop: - log.Printf("[graph] worker stopped for %s", account.EmailAddress) + logger.Debug("[graph] worker stopped for %s", account.EmailAddress) return case <-push: acc := getAccount() @@ -766,13 +807,11 @@ func (s *Scheduler) graphDeltaSync(account *models.EmailAccount) { continue } - // Determine how far back to fetch - var since time.Time - if account.SyncMode == "days" && account.SyncDays > 0 { - since = time.Now().AddDate(0, 0, -account.SyncDays) - } - - msgs, err := gc.ListMessages(ctx, gf.ID, since, 500) + // Fetch latest messages — no since filter, rely on upsert idempotency. + // Graph uses sentDateTime for sent items which differs from receivedDateTime, + // making date-based filters unreliable across folder types. + // Fetching top 100 newest per folder per sync is efficient enough. + msgs, err := gc.ListMessages(ctx, gf.ID, time.Time{}, 100) if err != nil { log.Printf("[graph:%s] list messages in %s: %v", account.EmailAddress, gf.DisplayName, err) continue @@ -805,6 +844,6 @@ func (s *Scheduler) graphDeltaSync(account *models.EmailAccount) { s.db.UpdateAccountLastSync(account.ID) if totalNew > 0 { - log.Printf("[graph:%s] %d new messages", account.EmailAddress, totalNew) + logger.Debug("[graph:%s] %d new messages", account.EmailAddress, totalNew) } } diff --git a/web/static/img/outlook.png b/web/static/img/outlook.png deleted file mode 100644 index 42ba7a6efcd1df4c1435470526f644fb369bf283..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4450 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 R-=hh_&c@lgk$OD+{{X$2=K%l! diff --git a/web/static/js/app.js b/web/static/js/app.js index 515d954..a608bbd 100644 --- a/web/static/js/app.js +++ b/web/static/js/app.js @@ -1484,7 +1484,13 @@ async function sendMessage() { } btn.disabled=false; btn.textContent='Send'; - if(r?.ok){ toast('Message sent!','success'); clearDraftAutosave(); _closeCompose(); } + if(r?.ok){ + toast('Message sent!','success'); + clearDraftAutosave(); + _closeCompose(); + // Refresh after a short delay so the syncer has time to pick up the sent message + setTimeout(async () => { await loadFolders(); await loadMessages(); }, 2500); + } else toast(r?.error||'Send failed','error'); } @@ -1757,7 +1763,7 @@ async function startPoller() { function schedulePoll() { if (!POLLER.active) return; - POLLER.timer = setTimeout(runPoll, 20000); // 20 second interval + POLLER.timer = setTimeout(runPoll, 10000); // 10 second interval } async function runPoll() { @@ -1783,15 +1789,9 @@ async function runPoll() { sendOSNotification(newMsgs); } - // Refresh current view if we're looking at inbox/unified - const isInboxView = S.currentFolder === 'unified' || - S.folders.find(f => f.id === S.currentFolder && f.folder_type === 'inbox'); - if (isInboxView) { - await loadMessages(); - await loadFolders(); - } else { - await loadFolders(); // update counts in sidebar - } + // Always refresh the message list and folder counts when new mail arrives + await loadFolders(); + await loadMessages(); } } catch(e) { // Network error — silent, retry next cycle diff --git a/web/templates/app.html b/web/templates/app.html index 55ea68b..f99cb74 100644 --- a/web/templates/app.html +++ b/web/templates/app.html @@ -193,15 +193,26 @@

Connect Gmail or Outlook via OAuth, or any email via IMAP/SMTP.

@@ -386,5 +397,5 @@ {{end}} {{define "scripts"}} - + {{end}} diff --git a/web/templates/base.html b/web/templates/base.html index 6a57d6c..8e26328 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -5,12 +5,12 @@ {{block "title" .}}GoWebMail{{end}} - + {{block "head_extra" .}}{{end}} {{block "body" .}}{{end}} - + {{block "scripts" .}}{{end}}