updated outlook account sync

This commit is contained in:
ghostersk
2026-03-15 20:27:29 +00:00
parent a9c7f4c575
commit 9e7e87d11b
14 changed files with 191 additions and 65 deletions

View File

@@ -15,6 +15,7 @@ import (
"github.com/ghostersk/gowebmail/config" "github.com/ghostersk/gowebmail/config"
"github.com/ghostersk/gowebmail/internal/db" "github.com/ghostersk/gowebmail/internal/db"
"github.com/ghostersk/gowebmail/internal/handlers" "github.com/ghostersk/gowebmail/internal/handlers"
"github.com/ghostersk/gowebmail/internal/logger"
"github.com/ghostersk/gowebmail/internal/middleware" "github.com/ghostersk/gowebmail/internal/middleware"
"github.com/ghostersk/gowebmail/internal/syncer" "github.com/ghostersk/gowebmail/internal/syncer"
@@ -72,6 +73,7 @@ func main() {
if err != nil { if err != nil {
log.Fatalf("config load: %v", err) log.Fatalf("config load: %v", err)
} }
logger.Init(cfg.Debug)
database, err := db.New(cfg.DBPath, cfg.EncryptionKey) database, err := db.New(cfg.DBPath, cfg.EncryptionKey)
if err != nil { if err != nil {

View File

@@ -21,6 +21,9 @@ type Config struct {
Hostname string // e.g. "mail.example.com" — used for BASE_URL and host checks Hostname string // e.g. "mail.example.com" — used for BASE_URL and host checks
BaseURL string // auto-built from Hostname + ListenPort, or overridden explicitly BaseURL string // auto-built from Hostname + ListenPort, or overridden explicitly
// Debug
Debug bool // set DEBUG=true in config to enable verbose logging
// Security // Security
EncryptionKey []byte // 32 bytes / AES-256 EncryptionKey []byte // 32 bytes / AES-256
SessionSecret []byte SessionSecret []byte
@@ -431,6 +434,7 @@ func Load() (*Config, error) {
Hostname: hostname, Hostname: hostname,
BaseURL: baseURL, BaseURL: baseURL,
DBPath: get("DB_PATH"), DBPath: get("DB_PATH"),
Debug: atobool(get("DEBUG"), false),
EncryptionKey: encKey, EncryptionKey: encKey,
SessionSecret: []byte(sessSecret), SessionSecret: []byte(sessSecret),
SecureCookie: atobool(get("SECURE_COOKIE"), false), SecureCookie: atobool(get("SECURE_COOKIE"), false),

View File

@@ -6,12 +6,12 @@ import (
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log"
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
"time" "time"
"github.com/ghostersk/gowebmail/internal/logger"
"golang.org/x/oauth2" "golang.org/x/oauth2"
"golang.org/x/oauth2/google" "golang.org/x/oauth2/google"
"golang.org/x/oauth2/microsoft" "golang.org/x/oauth2/microsoft"
@@ -21,7 +21,7 @@ import (
// GmailScopes are the OAuth2 scopes required for full Gmail access. // GmailScopes are the OAuth2 scopes required for full Gmail access.
var GmailScopes = []string{ 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.email",
"https://www.googleapis.com/auth/userinfo.profile", "https://www.googleapis.com/auth/userinfo.profile",
} }
@@ -148,7 +148,7 @@ func ExchangeForIMAPToken(ctx context.Context, clientID, clientSecret, tenantID,
preview = preview[:30] + "..." preview = preview[:30] + "..."
} }
parts := strings.Count(result.AccessToken, ".") + 1 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")) parts, preview, params.Get("scope"))
expiry := time.Now().Add(time.Duration(result.ExpiresIn) * time.Second) 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. // MicrosoftUserInfo holds user info extracted from the Microsoft ID token.
type MicrosoftUserInfo struct { type MicrosoftUserInfo struct {
ID string `json:"id"` ID string `json:"id"`
DisplayName string `json:"displayName"` // Graph field DisplayName string `json:"displayName"` // Graph field
Name string `json:"name"` // ID token claim Name string `json:"name"` // ID token claim
Mail string `json:"mail"` Mail string `json:"mail"`
EmailClaim string `json:"email"` // ID token claim EmailClaim string `json:"email"` // ID token claim
UserPrincipalName string `json:"userPrincipalName"` UserPrincipalName string `json:"userPrincipalName"`
PreferredUsername string `json:"preferred_username"` // ID token claim PreferredUsername string `json:"preferred_username"` // ID token claim
} }

View File

@@ -1235,6 +1235,14 @@ func (d *DB) UpdateMessageBody(messageID int64, bodyText, bodyHTML string) {
bodyTextEnc, bodyHTMLEnc, messageID) 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) { func (d *DB) ToggleMessageStar(messageID, userID int64) (bool, error) {
var current bool var current bool
err := d.sql.QueryRow(` err := d.sql.QueryRow(`

View File

@@ -19,6 +19,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/ghostersk/gowebmail/internal/logger"
"github.com/emersion/go-imap" "github.com/emersion/go-imap"
"github.com/emersion/go-imap/client" "github.com/emersion/go-imap/client"
@@ -64,9 +65,9 @@ func (x *xoauth2Client) Next(challenge []byte) ([]byte, error) {
if len(challenge) > 0 { if len(challenge) > 0 {
// Decode and log the error from Microsoft so it appears in server logs // Decode and log the error from Microsoft so it appears in server logs
if dec, err := base64.StdEncoding.DecodeString(string(challenge)); err == nil { 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 { } 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 // Send empty response to let the server send the final error
return []byte("\x01"), nil return []byte("\x01"), nil
@@ -142,18 +143,18 @@ func Connect(ctx context.Context, account *gomailModels.EmailAccount) (*Client,
Upn string `json:"upn"` Upn string `json:"upn"`
} }
if json.Unmarshal(payload, &claims) == nil { 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) account.EmailAddress, claims.Aud, claims.Scp, tokenPreview)
} else { } 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) account.EmailAddress, string(payload), tokenPreview)
} }
} else { } else {
log.Printf("[imap:connect] %s opaque token (not JWT): %s", logger.Debug("[imap:connect] %s opaque token (not JWT): %s",
account.EmailAddress, tokenPreview) account.EmailAddress, tokenPreview)
} }
} else { } 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) account.EmailAddress, len(strings.Split(account.AccessToken, ".")), tokenPreview)
} }
sasl := &xoauth2Client{user: account.EmailAddress, token: account.AccessToken} sasl := &xoauth2Client{user: account.EmailAddress, token: account.AccessToken}
@@ -900,7 +901,7 @@ func SendMessageFull(ctx context.Context, account *gomailModels.EmailAccount, re
rawMsg := buf.Bytes() rawMsg := buf.Bytes()
addr := fmt.Sprintf("%s:%d", host, port) 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 c *smtp.Client
var err error var err error
@@ -941,7 +942,7 @@ func SendMessageFull(ctx context.Context, account *gomailModels.EmailAccount, re
if err := authSMTP(c, account, host); err != nil { if err := authSMTP(c, account, host); err != nil {
return fmt.Errorf("SMTP auth failed for %s: %w", account.EmailAddress, err) 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 { if err := c.Mail(account.EmailAddress); err != nil {
return fmt.Errorf("SMTP MAIL FROM <%s>: %w", account.EmailAddress, err) 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 // DATA close is where the server accepts or rejects the message
return fmt.Errorf("SMTP server rejected message: %w", err) 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() _ = c.Quit()
// Append to Sent folder via IMAP (best-effort, don't fail the send) // Append to Sent folder via IMAP (best-effort, don't fail the send)

View File

@@ -12,6 +12,7 @@ import (
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
"regexp"
"strings" "strings"
"time" "time"
@@ -198,8 +199,10 @@ type messagesResp struct {
func (c *Client) ListMessages(ctx context.Context, folderID string, since time.Time, maxResults int) ([]GraphMessage, error) { func (c *Client) ListMessages(ctx context.Context, folderID string, since time.Time, maxResults int) ([]GraphMessage, error) {
filter := "" filter := ""
if !since.IsZero() { if !since.IsZero() {
filter = "&$filter=receivedDateTime+gt+" + // OData filter: receivedDateTime gt 2006-01-02T15:04:05Z
url.QueryEscape(since.UTC().Format("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 top := 50
if maxResults > 0 && maxResults < top { if maxResults > 0 && maxResults < top {
@@ -322,25 +325,51 @@ func WellKnownToFolderType(wk string) string {
// ---- Send mail ---- // ---- 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)<br\s*/?>|</p>|</div>|</li>|</tr>`).ReplaceAllString(s, "\n")
s = regexp.MustCompile(`<[^>]+>`).ReplaceAllString(s, "")
s = strings.NewReplacer("&amp;", "&", "&lt;", "<", "&gt;", ">", "&quot;", `"`, "&#39;", "'", "&nbsp;", " ").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. // 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 { func (c *Client) SendMail(ctx context.Context, req *models.ComposeRequest) error {
contentType := "HTML" // Build body: always provide both HTML and plain text for better deliverability
body := req.BodyHTML body := map[string]string{
if body == "" { "contentType": "HTML",
contentType = "Text" "content": req.BodyHTML,
body = req.BodyText }
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{}{ msg := map[string]interface{}{
"subject": req.Subject, "subject": req.Subject,
"body": map[string]string{ "body": body,
"contentType": contentType,
"content": body,
},
"toRecipients": graphRecipients(req.To), "toRecipients": graphRecipients(req.To),
"ccRecipients": graphRecipients(req.CC), "ccRecipients": graphRecipients(req.CC),
"bccRecipients": graphRecipients(req.BCC), "bccRecipients": graphRecipients(req.BCC),
} }
if fromField != nil {
msg["from"] = fromField
}
if len(req.Attachments) > 0 { if len(req.Attachments) > 0 {
var atts []map[string]interface{} var atts []map[string]interface{}

View File

@@ -844,6 +844,11 @@ func (h *APIHandler) handleSend(w http.ResponseWriter, r *http.Request, mode str
h.writeError(w, http.StatusBadGateway, err.Error()) h.writeError(w, http.StatusBadGateway, err.Error())
return 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}) h.writeJSON(w, map[string]bool{"ok": true})
return return
} }
@@ -856,6 +861,8 @@ func (h *APIHandler) handleSend(w http.ResponseWriter, r *http.Request, mode str
h.writeError(w, http.StatusBadGateway, err.Error()) h.writeError(w, http.StatusBadGateway, err.Error())
return 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}) h.writeJSON(w, map[string]bool{"ok": true})
} }

View File

@@ -12,6 +12,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/ghostersk/gowebmail/internal/logger"
"github.com/ghostersk/gowebmail/config" "github.com/ghostersk/gowebmail/config"
goauth "github.com/ghostersk/gowebmail/internal/auth" goauth "github.com/ghostersk/gowebmail/internal/auth"
"github.com/ghostersk/gowebmail/internal/crypto" "github.com/ghostersk/gowebmail/internal/crypto"
@@ -430,7 +431,7 @@ h2{color:#f87171} a{color:#6b8afd}</style></head><body>
http.Redirect(w, r, "/?error=userinfo_failed", http.StatusFound) http.Redirect(w, r, "/?error=userinfo_failed", http.StatusFound)
return 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 // Exchange initial token for one scoped to https://outlook.office.com
// so IMAP auth succeeds (aud must be outlook.office.com not graph/live) // so IMAP auth succeeds (aud must be outlook.office.com not graph/live)
@@ -440,10 +441,10 @@ h2{color:#f87171} a{color:#6b8afd}</style></head><body>
h.cfg.MicrosoftTenantID, token.RefreshToken, h.cfg.MicrosoftTenantID, token.RefreshToken,
) )
if err != nil { 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 imapToken = token
} else { } 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 == "" { if imapToken.RefreshToken == "" {
imapToken.RefreshToken = token.RefreshToken imapToken.RefreshToken = token.RefreshToken
} }
@@ -703,7 +704,7 @@ Mail.ReadWrite, Mail.Send, User.Read, openid, email, offline_access</p>
// Verify it's a JWT (Graph token for personal accounts should be a JWT) // Verify it's a JWT (Graph token for personal accounts should be a JWT)
tokenParts := len(strings.Split(token.AccessToken, ".")) 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) userInfo.Email(), tokenParts)
accounts, _ := h.db.ListAccountsByUser(userID) accounts, _ := h.db.ListAccountsByUser(userID)

24
internal/logger/logger.go Normal file
View File

@@ -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 }

View File

@@ -13,6 +13,7 @@ import (
"sync" "sync"
"time" "time"
"github.com/ghostersk/gowebmail/internal/logger"
"github.com/ghostersk/gowebmail/config" "github.com/ghostersk/gowebmail/config"
"github.com/ghostersk/gowebmail/internal/auth" "github.com/ghostersk/gowebmail/internal/auth"
"github.com/ghostersk/gowebmail/internal/db" "github.com/ghostersk/gowebmail/internal/db"
@@ -427,7 +428,7 @@ func (s *Scheduler) deltaSync(account *models.EmailAccount) {
s.db.UpdateAccountLastSync(account.ID) s.db.UpdateAccountLastSync(account.ID)
if totalNew > 0 { 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 return
} }
if n > 0 { 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. // Applies queued IMAP write operations (delete/move/flag) with retry logic.
func (s *Scheduler) drainPendingOps(account *models.EmailAccount) { 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) ops, err := s.db.DequeuePendingOps(account.ID, 50)
if err != nil || len(ops) == 0 { if err != nil || len(ops) == 0 {
return return
@@ -608,10 +613,10 @@ func (s *Scheduler) ensureFreshToken(account *models.EmailAccount) *models.Email
return account return account
} }
if isOpaque { 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 == "" { 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 return account
} }
@@ -627,12 +632,12 @@ func (s *Scheduler) ensureFreshToken(account *models.EmailAccount) *models.Email
s.cfg.MicrosoftClientID, s.cfg.MicrosoftClientSecret, s.cfg.MicrosoftTenantID, s.cfg.MicrosoftClientID, s.cfg.MicrosoftClientSecret, s.cfg.MicrosoftTenantID,
) )
if err != nil { 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()) s.db.SetAccountError(account.ID, "OAuth token refresh failed: "+err.Error())
return account // return original; connect will fail and log the error return account // return original; connect will fail and log the error
} }
if err := s.db.UpdateAccountTokens(account.ID, accessTok, refreshTok, expiry); err != nil { 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 return account
} }
@@ -641,7 +646,7 @@ func (s *Scheduler) ensureFreshToken(account *models.EmailAccount) *models.Email
if fetchErr != nil || refreshed == nil { if fetchErr != nil || refreshed == nil {
return account 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 return refreshed
} }
@@ -669,6 +674,42 @@ func (s *Scheduler) SyncFolderNow(accountID, folderID int64) (int, error) {
return 0, fmt.Errorf("folder %d not found", folderID) 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) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel() defer cancel()
account = s.ensureFreshToken(account) 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. // graphWorker is the accountWorker equivalent for ProviderOutlookPersonal accounts.
// It polls Graph API instead of using IMAP. // It polls Graph API instead of using IMAP.
func (s *Scheduler) graphWorker(account *models.EmailAccount, stop chan struct{}, push chan struct{}) { 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 { getAccount := func() *models.EmailAccount {
a, _ := s.db.GetAccount(account.ID) a, _ := s.db.GetAccount(account.ID)
@@ -705,7 +746,7 @@ func (s *Scheduler) graphWorker(account *models.EmailAccount, stop chan struct{}
for { for {
select { select {
case <-stop: case <-stop:
log.Printf("[graph] worker stopped for %s", account.EmailAddress) logger.Debug("[graph] worker stopped for %s", account.EmailAddress)
return return
case <-push: case <-push:
acc := getAccount() acc := getAccount()
@@ -766,13 +807,11 @@ func (s *Scheduler) graphDeltaSync(account *models.EmailAccount) {
continue continue
} }
// Determine how far back to fetch // Fetch latest messages — no since filter, rely on upsert idempotency.
var since time.Time // Graph uses sentDateTime for sent items which differs from receivedDateTime,
if account.SyncMode == "days" && account.SyncDays > 0 { // making date-based filters unreliable across folder types.
since = time.Now().AddDate(0, 0, -account.SyncDays) // Fetching top 100 newest per folder per sync is efficient enough.
} msgs, err := gc.ListMessages(ctx, gf.ID, time.Time{}, 100)
msgs, err := gc.ListMessages(ctx, gf.ID, since, 500)
if err != nil { if err != nil {
log.Printf("[graph:%s] list messages in %s: %v", account.EmailAddress, gf.DisplayName, err) log.Printf("[graph:%s] list messages in %s: %v", account.EmailAddress, gf.DisplayName, err)
continue continue
@@ -805,6 +844,6 @@ func (s *Scheduler) graphDeltaSync(account *models.EmailAccount) {
s.db.UpdateAccountLastSync(account.ID) s.db.UpdateAccountLastSync(account.ID)
if totalNew > 0 { if totalNew > 0 {
log.Printf("[graph:%s] %d new messages", account.EmailAddress, totalNew) logger.Debug("[graph:%s] %d new messages", account.EmailAddress, totalNew)
} }
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

View File

@@ -1484,7 +1484,13 @@ async function sendMessage() {
} }
btn.disabled=false; btn.textContent='Send'; 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'); else toast(r?.error||'Send failed','error');
} }
@@ -1757,7 +1763,7 @@ async function startPoller() {
function schedulePoll() { function schedulePoll() {
if (!POLLER.active) return; if (!POLLER.active) return;
POLLER.timer = setTimeout(runPoll, 20000); // 20 second interval POLLER.timer = setTimeout(runPoll, 10000); // 10 second interval
} }
async function runPoll() { async function runPoll() {
@@ -1783,15 +1789,9 @@ async function runPoll() {
sendOSNotification(newMsgs); sendOSNotification(newMsgs);
} }
// Refresh current view if we're looking at inbox/unified // Always refresh the message list and folder counts when new mail arrives
const isInboxView = S.currentFolder === 'unified' || await loadFolders();
S.folders.find(f => f.id === S.currentFolder && f.folder_type === 'inbox'); await loadMessages();
if (isInboxView) {
await loadMessages();
await loadFolders();
} else {
await loadFolders(); // update counts in sidebar
}
} }
} catch(e) { } catch(e) {
// Network error — silent, retry next cycle // Network error — silent, retry next cycle

View File

@@ -193,15 +193,26 @@
<p>Connect Gmail or Outlook via OAuth, or any email via IMAP/SMTP.</p> <p>Connect Gmail or Outlook via OAuth, or any email via IMAP/SMTP.</p>
<div class="provider-btns"> <div class="provider-btns">
<button class="provider-btn" id="btn-gmail" onclick="connectOAuth('gmail')"> <button class="provider-btn" id="btn-gmail" onclick="connectOAuth('gmail')">
<svg viewBox="0 0 24 24" width="18" height="18"><path fill="#EA4335" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/><path fill="#4285F4" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/><path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/><path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/></svg> <svg viewBox="0 0 24 24" width="20" height="20"><path fill="#EA4335" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/><path fill="#4285F4" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/><path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/><path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/></svg>
Gmail Gmail
</button> </button>
<button class="provider-btn" id="btn-outlook" onclick="connectOAuth('outlook')"> <button class="provider-btn" id="btn-outlook" onclick="connectOAuth('outlook')">
<svg viewBox="0 0 24 24" width="18" height="18" fill="#0078D4"><path d="M21.179 4.781H11.25V12h9.929V4.781zM11.25 19.219h9.929V12H11.25v7.219zM2.821 12H11.25V4.781H2.821V12zm0 7.219H11.25V12H2.821v7.219z"/></svg> <!-- Microsoft 365 icon -->
<svg viewBox="0 0 24 24" width="20" height="20" xmlns="http://www.w3.org/2000/svg">
<path fill="#EA3E23" d="M11.4 4H4v7.4h7.4V4z"/>
<path fill="#0364B8" d="M11.4 12.6H4V20h7.4v-7.4z"/>
<path fill="#0078D4" d="M20 4h-7.4v7.4H20V4z"/>
<path fill="#28A8E8" d="M20 12.6h-7.4V20H20v-7.4z"/>
</svg>
Microsoft 365 Microsoft 365
</button> </button>
<button class="provider-btn" id="btn-outlook-personal" onclick="connectOAuth('outlook_personal')"> <button class="provider-btn" id="btn-outlook-personal" onclick="connectOAuth('outlook_personal')">
<svg viewBox="0 0 24 24" width="18" height="18" fill="#0078D4"><path d="M21.179 4.781H11.25V12h9.929V4.781zM11.25 19.219h9.929V12H11.25v7.219zM2.821 12H11.25V4.781H2.821V12zm0 7.219H11.25V12H2.821v7.219z"/></svg> <!-- Outlook icon (blue envelope) -->
<svg viewBox="0 0 24 24" width="20" height="20" xmlns="http://www.w3.org/2000/svg">
<rect width="24" height="24" rx="3" fill="#0078D4"/>
<path fill="white" d="M6 7h12v10H6z" opacity=".2"/>
<path fill="white" d="M6 7l6 5 6-5H6zm0 1.5V17h12V8.5l-6 5-6-5z"/>
</svg>
Outlook Personal Outlook Personal
</button> </button>
</div> </div>
@@ -386,5 +397,5 @@
{{end}} {{end}}
{{define "scripts"}} {{define "scripts"}}
<script src="/static/js/app.js?v=49"></script> <script src="/static/js/app.js?v=54"></script>
{{end}} {{end}}

View File

@@ -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=49"> <link rel="stylesheet" href="/static/css/gowebmail.css?v=54">
{{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=49"></script> <script src="/static/js/gowebmail.js?v=54"></script>
{{block "scripts" .}}{{end}} {{block "scripts" .}}{{end}}
</body> </body>
</html> </html>