mirror of
https://github.com/ghostersk/gowebmail.git
synced 2026-04-17 00:26:01 +01:00
updated outlook account sync
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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("&", "&", "<", "<", ">", ">", """, `"`, "'", "'", " ", " ").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{}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
internal/logger/logger.go
Normal file
24
internal/logger/logger.go
Normal 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 }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.3 KiB |
@@ -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
|
||||
|
||||
@@ -193,15 +193,26 @@
|
||||
<p>Connect Gmail or Outlook via OAuth, or any email via IMAP/SMTP.</p>
|
||||
<div class="provider-btns">
|
||||
<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
|
||||
</button>
|
||||
<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
|
||||
</button>
|
||||
<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
|
||||
</button>
|
||||
</div>
|
||||
@@ -386,5 +397,5 @@
|
||||
{{end}}
|
||||
|
||||
{{define "scripts"}}
|
||||
<script src="/static/js/app.js?v=49"></script>
|
||||
<script src="/static/js/app.js?v=54"></script>
|
||||
{{end}}
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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 rel="stylesheet" href="/static/css/gowebmail.css?v=49">
|
||||
<link rel="stylesheet" href="/static/css/gowebmail.css?v=54">
|
||||
{{block "head_extra" .}}{{end}}
|
||||
</head>
|
||||
<body class="{{block "body_class" .}}{{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}}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user