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
+6 -6
View File
@@ -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
}
+8
View File
@@ -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(`
+10 -9
View File
@@ -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)
+41 -12
View File
@@ -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)<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.
// 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{}
+7
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())
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})
}
+5 -4
View File
@@ -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}</style></head><body>
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}</style></head><body>
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</p>
// 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)
+24
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 }
+56 -17
View File
@@ -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)
}
}