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 42ba7a6..0000000
Binary files a/web/static/img/outlook.png and /dev/null differ
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}}