From ce9aaa5fea674fa2527603ff40c3245da92cd2b0 Mon Sep 17 00:00:00 2001 From: Aravinda-HWK Date: Sun, 21 Jun 2026 14:57:01 +0530 Subject: [PATCH] feat: incremental (delta) sync for mailbox refresh Implements Phase 3 of the Email 2.0 proposal: refresh now fetches only what changed instead of re-listing the whole folder. Because go-imap v1 exposes no CONDSTORE/MODSEQ, the delta is computed from UIDs + flags rather than a MODSEQ token: Server - New GET /api/v1/mailboxes/{mailbox}/changes endpoint returning a MailboxDelta {uidvalidity, total, resync, added, flags, removed}. - imap.MailboxChanges: UID SEARCH (since+1):* for new envelopes, a flags-only UID FETCH over the client's known UIDs to derive flag changes + removals; UIDVALIDITY mismatch signals a full resync. - ListMessages now also returns uidvalidity so the client has a baseline. - Factored shared fetchEnvelopes/fetchFlags helpers. Client - Dexie v2 syncMeta table persists uidvalidity per folder. - mailboxes.changes() endpoint + MailboxDelta/FlagUpdate types. - DataContext reconciles added/removed/flag changes idempotently (keyed on UID) and exposes refreshFolder(role); a ThreadList refresh button wires it into Inbox/Sent/Trash. Performance - refreshFolder syncs only the viewed folder, so four folder syncs no longer serialize on the session's single IMAP connection. - IMAPFor drops a redundant per-request NOOP probe (one RTT) since every operation already self-heals via ensureLive. Tests: parseUIDList unit tests; server build + go test and frontend typecheck pass. --- server/internal/http/handlers/mailboxes.go | 63 ++++++- .../internal/http/handlers/mailboxes_test.go | 48 ++++++ server/internal/http/router.go | 1 + server/internal/imap/client.go | 159 +++++++++++++++--- server/internal/mail/types.go | 30 ++++ server/internal/session/store.go | 13 +- src/nonview/api/endpoints.ts | 20 +++ src/nonview/api/types.ts | 23 +++ src/nonview/cache/db.ts | Bin 5367 -> 6966 bytes src/nonview/core/DataContext.tsx | 136 ++++++++++++++- src/view/moles/ThreadList.tsx | 79 ++++++--- src/view/pages/InboxPage.tsx | 3 +- src/view/pages/SentPage.tsx | 2 + src/view/pages/TrashPage.tsx | 2 + 14 files changed, 523 insertions(+), 56 deletions(-) create mode 100644 server/internal/http/handlers/mailboxes_test.go diff --git a/server/internal/http/handlers/mailboxes.go b/server/internal/http/handlers/mailboxes.go index 3e951b1..ac44ffe 100644 --- a/server/internal/http/handlers/mailboxes.go +++ b/server/internal/http/handlers/mailboxes.go @@ -5,6 +5,7 @@ import ( "net/http" "net/url" "strconv" + "strings" "github.com/go-chi/chi/v5" @@ -63,14 +64,72 @@ func (h *Mailboxes) ListMessages(w http.ResponseWriter, r *http.Request) { httpx.WriteError(w, r, h.Logger, httpx.NewAPIError(http.StatusBadGateway, httpx.CodeUpstreamFailed, "imap connect", err)) return } - envelopes, total, err := c.ListMessages(r.Context(), mailbox, limit, uint32(before)) + envelopes, total, uidvalidity, err := c.ListMessages(r.Context(), mailbox, limit, uint32(before)) if err != nil { httpx.WriteError(w, r, h.Logger, httpx.NewAPIError(http.StatusBadGateway, httpx.CodeUpstreamFailed, "list messages", err)) return } - resp := map[string]any{"messages": envelopes, "total": total} + resp := map[string]any{"messages": envelopes, "total": total, "uidvalidity": uidvalidity} if len(envelopes) > 0 { resp["next_before"] = envelopes[len(envelopes)-1].UID } httpx.WriteJSON(w, http.StatusOK, resp) } + +// Changes returns an incremental-sync delta for the named mailbox (proposal §6), +// so the client refreshes by fetching only what changed rather than re-listing. +// +// Query params: +// - uidvalidity (the client's cached UIDVALIDITY; 0/absent on first sync) +// - known (comma-separated UIDs the client currently holds) +// - limit (1..200, default 50 — caps how many new envelopes are returned) +func (h *Mailboxes) Changes(w http.ResponseWriter, r *http.Request) { + sess, ok := middleware.SessionFrom(r.Context()) + if !ok { + httpx.WriteError(w, r, h.Logger, httpx.NewAPIError(http.StatusUnauthorized, httpx.CodeUnauthorized, "no session", nil)) + return + } + mailbox, err := url.PathUnescape(chi.URLParam(r, "mailbox")) + if err != nil || mailbox == "" { + httpx.WriteError(w, r, h.Logger, httpx.NewAPIError(http.StatusBadRequest, httpx.CodeBadRequest, "invalid mailbox name", err)) + return + } + q := r.URL.Query() + uidvalidity, _ := strconv.ParseUint(q.Get("uidvalidity"), 10, 32) + limit, _ := strconv.Atoi(q.Get("limit")) + known := parseUIDList(q.Get("known")) + + c, err := h.Sessions.IMAPFor(r.Context(), sess) + if err != nil { + httpx.WriteError(w, r, h.Logger, httpx.NewAPIError(http.StatusBadGateway, httpx.CodeUpstreamFailed, "imap connect", err)) + return + } + delta, err := c.MailboxChanges(r.Context(), mailbox, uint32(uidvalidity), known, limit) + if err != nil { + httpx.WriteError(w, r, h.Logger, httpx.NewAPIError(http.StatusBadGateway, httpx.CodeUpstreamFailed, "mailbox changes", err)) + return + } + httpx.WriteJSON(w, http.StatusOK, delta) +} + +// parseUIDList parses a comma-separated list of UIDs, skipping any malformed or +// zero entries. Caps the input to a sane bound so a runaway query string can't +// force an unbounded FETCH. +func parseUIDList(s string) []uint32 { + if s == "" { + return nil + } + parts := strings.Split(s, ",") + if len(parts) > 1000 { + parts = parts[:1000] + } + out := make([]uint32, 0, len(parts)) + for _, p := range parts { + n, err := strconv.ParseUint(strings.TrimSpace(p), 10, 32) + if err != nil || n == 0 { + continue + } + out = append(out, uint32(n)) + } + return out +} diff --git a/server/internal/http/handlers/mailboxes_test.go b/server/internal/http/handlers/mailboxes_test.go new file mode 100644 index 0000000..97eb95d --- /dev/null +++ b/server/internal/http/handlers/mailboxes_test.go @@ -0,0 +1,48 @@ +package handlers + +import ( + "reflect" + "testing" +) + +func TestParseUIDList(t *testing.T) { + tests := []struct { + name string + in string + want []uint32 + }{ + {"empty", "", nil}, + {"single", "42", []uint32{42}}, + {"multiple", "1,2,3", []uint32{1, 2, 3}}, + {"whitespace", " 1 , 2 ,3 ", []uint32{1, 2, 3}}, + {"skips zero", "0,5,0,7", []uint32{5, 7}}, + {"skips malformed", "1,abc,3,,5", []uint32{1, 3, 5}}, + {"all invalid", "x,y,z", []uint32{}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseUIDList(tt.in) + if len(got) == 0 && len(tt.want) == 0 { + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("parseUIDList(%q) = %v, want %v", tt.in, got, tt.want) + } + }) + } +} + +func TestParseUIDListCapsInput(t *testing.T) { + // Build a 1500-entry list; parser must cap the parsed set at 1000 so a + // runaway query string can't force an unbounded FETCH. + in := "" + for i := 0; i < 1500; i++ { + if i > 0 { + in += "," + } + in += "1" + } + if got := len(parseUIDList(in)); got > 1000 { + t.Errorf("parseUIDList did not cap input: got %d entries, want <= 1000", got) + } +} diff --git a/server/internal/http/router.go b/server/internal/http/router.go index caec548..50eaee5 100644 --- a/server/internal/http/router.go +++ b/server/internal/http/router.go @@ -63,6 +63,7 @@ func NewRouter(d Deps) http.Handler { r.Use(requireSession) r.Get("/mailboxes", mboxH.List) r.Get("/mailboxes/{mailbox}/messages", mboxH.ListMessages) + r.Get("/mailboxes/{mailbox}/changes", mboxH.Changes) r.Get("/mailboxes/{mailbox}/messages/{uid}", msgH.Get) r.Patch("/mailboxes/{mailbox}/messages/{uid}/flags", msgH.SetFlags) r.Delete("/mailboxes/{mailbox}/messages/{uid}", msgH.Delete) diff --git a/server/internal/imap/client.go b/server/internal/imap/client.go index 2e285d9..9dbcb4a 100644 --- a/server/internal/imap/client.go +++ b/server/internal/imap/client.go @@ -176,26 +176,26 @@ func (c *Client) selectMailbox(name string, readOnly bool) error { // ListMessages returns up to limit envelopes from the given mailbox, newest first. // If before > 0, only messages with UID < before are returned (cursor-style paging). // The returned total is the mailbox's full message count (independent of the -// page), suitable for a "1–50 of N" pager. -func (c *Client) ListMessages(ctx context.Context, mailbox string, limit int, before uint32) ([]hmail.Envelope, uint32, error) { +// page), suitable for a "1–50 of N" pager. uidvalidity is the mailbox's current +// UIDVALIDITY, which the client persists to detect cache-invalidating changes +// on a later delta sync. +func (c *Client) ListMessages(ctx context.Context, mailbox string, limit int, before uint32) (envs []hmail.Envelope, total, uidvalidity uint32, err error) { if limit <= 0 || limit > 200 { limit = 50 } c.mu.Lock() defer c.mu.Unlock() if err := c.ensureLive(ctx); err != nil { - return nil, 0, err - } - if err := c.selectMailbox(mailbox, true); err != nil { - return nil, 0, err + return nil, 0, 0, err } mbox, err := c.conn.Select(mailbox, true) if err != nil { - return nil, 0, err + return nil, 0, 0, fmt.Errorf("select %q: %w", mailbox, err) } - total := mbox.Messages + c.selected = mailbox + total, uidvalidity = mbox.Messages, mbox.UidValidity if total == 0 { - return []hmail.Envelope{}, 0, nil + return []hmail.Envelope{}, 0, uidvalidity, nil } // Fetch the highest UID first; if before is set, cap the upper bound there. @@ -207,35 +207,150 @@ func (c *Client) ListMessages(ctx context.Context, mailbox string, limit int, be } uids, err := c.conn.UidSearch(criteria) if err != nil { - return nil, 0, fmt.Errorf("uid search: %w", err) + return nil, 0, 0, fmt.Errorf("uid search: %w", err) } if len(uids) == 0 { - return []hmail.Envelope{}, total, nil + return []hmail.Envelope{}, total, uidvalidity, nil } // Newest first; take the last `limit` UIDs. if len(uids) > limit { uids = uids[len(uids)-limit:] } + envelopes, err := c.fetchEnvelopes(uids) + if err != nil { + return nil, 0, 0, err + } + reverseEnvelopes(envelopes) // UID-ascending fetch → newest-first + return envelopes, total, uidvalidity, nil +} + +// MailboxChanges computes an incremental-sync delta for a mailbox given the +// client's last-known state (proposal §6). knownValidity is the UIDVALIDITY the +// client cached for this folder (0 if none); known is the set of UIDs it +// currently holds. See hmail.MailboxDelta for the contract. +// +// Caller does not need the mailbox SELECTed beforehand. +func (c *Client) MailboxChanges(ctx context.Context, mailbox string, knownValidity uint32, known []uint32, limit int) (hmail.MailboxDelta, error) { + if limit <= 0 || limit > 200 { + limit = 50 + } + c.mu.Lock() + defer c.mu.Unlock() + + var delta hmail.MailboxDelta + if err := c.ensureLive(ctx); err != nil { + return delta, err + } + mbox, err := c.conn.Select(mailbox, true) + if err != nil { + return delta, fmt.Errorf("select %q: %w", mailbox, err) + } + c.selected = mailbox + delta.UIDValidity, delta.Total = mbox.UidValidity, mbox.Messages + + // UIDVALIDITY changed → the client's cached UIDs no longer identify the same + // messages. Signal a full resync and skip the (now meaningless) diff. + if knownValidity != 0 && knownValidity != mbox.UidValidity { + delta.Resync = true + return delta, nil + } + + // Watermark: IMAP UIDs increase monotonically, so anything strictly greater + // than the highest UID the client holds is genuinely new. + var sinceUID uint32 + for _, u := range known { + if u > sinceUID { + sinceUID = u + } + } + + // 1. Added — UIDs in (sinceUID+1):*, capped to the newest `limit`. + if mbox.Messages > 0 && sinceUID < ^uint32(0) { + crit := imap.NewSearchCriteria() + seq := new(imap.SeqSet) + seq.AddRange(sinceUID+1, 0) // "(sinceUID+1):*" — 0 means "*" + crit.Uid = seq + newUIDs, err := c.conn.UidSearch(crit) + if err != nil { + return delta, fmt.Errorf("uid search added: %w", err) + } + if len(newUIDs) > limit { + newUIDs = newUIDs[len(newUIDs)-limit:] + } + if len(newUIDs) > 0 { + added, err := c.fetchEnvelopes(newUIDs) + if err != nil { + return delta, err + } + reverseEnvelopes(added) // newest-first, matching ListMessages + delta.Added = added + } + } + + // 2. Flags + removals among the known set. A known UID absent from the + // FLAGS fetch has been expunged or moved away. + if len(known) > 0 { + present, err := c.fetchFlags(known) + if err != nil { + return delta, err + } + for _, u := range known { + if fl, ok := present[u]; ok { + delta.Flags = append(delta.Flags, hmail.FlagUpdate{UID: u, Flags: fl}) + } else { + delta.Removed = append(delta.Removed, u) + } + } + } + return delta, nil +} + +// fetchEnvelopes fetches list-view envelopes for the given UIDs in UID-ascending +// order. Caller must hold c.mu and have the mailbox SELECTed. +func (c *Client) fetchEnvelopes(uids []uint32) ([]hmail.Envelope, error) { seq := new(imap.SeqSet) seq.AddNum(uids...) - msgs := make(chan *imap.Message, len(uids)) - fetchDone := make(chan error, 1) + done := make(chan error, 1) items := []imap.FetchItem{imap.FetchEnvelope, imap.FetchFlags, imap.FetchUid, imap.FetchBodyStructure} - go func() { fetchDone <- c.conn.UidFetch(seq, items, msgs) }() + go func() { done <- c.conn.UidFetch(seq, items, msgs) }() - var envelopes []hmail.Envelope + var out []hmail.Envelope for m := range msgs { - envelopes = append(envelopes, envelopeFrom(m)) + out = append(out, envelopeFrom(m)) } - if err := <-fetchDone; err != nil { - return nil, 0, fmt.Errorf("fetch envelopes: %w", err) + if err := <-done; err != nil { + return nil, fmt.Errorf("fetch envelopes: %w", err) } - // Sort newest-first by UID descending. - for i, j := 0, len(envelopes)-1; i < j; i, j = i+1, j-1 { - envelopes[i], envelopes[j] = envelopes[j], envelopes[i] + return out, nil +} + +// fetchFlags fetches only the flags for the given UIDs, returned as a uid→flags +// map. UIDs that no longer exist are simply omitted from the result. Caller must +// hold c.mu and have the mailbox SELECTed. +func (c *Client) fetchFlags(uids []uint32) (map[uint32][]string, error) { + seq := new(imap.SeqSet) + seq.AddNum(uids...) + msgs := make(chan *imap.Message, len(uids)) + done := make(chan error, 1) + items := []imap.FetchItem{imap.FetchFlags, imap.FetchUid} + go func() { done <- c.conn.UidFetch(seq, items, msgs) }() + + out := make(map[uint32][]string, len(uids)) + for m := range msgs { + out[m.Uid] = append([]string(nil), m.Flags...) + } + if err := <-done; err != nil { + return nil, fmt.Errorf("fetch flags: %w", err) + } + return out, nil +} + +// reverseEnvelopes flips a UID-ascending slice in place to newest-first. +func reverseEnvelopes(e []hmail.Envelope) { + for i, j := 0, len(e)-1; i < j; i, j = i+1, j-1 { + e[i], e[j] = e[j], e[i] } - return envelopes, total, nil } func envelopeFrom(m *imap.Message) hmail.Envelope { diff --git a/server/internal/mail/types.go b/server/internal/mail/types.go index 408e1eb..492405e 100644 --- a/server/internal/mail/types.go +++ b/server/internal/mail/types.go @@ -48,6 +48,36 @@ type Envelope struct { Preview string `json:"preview,omitempty"` } +// FlagUpdate carries the current IMAP flags for a single known message. The +// delta endpoint returns one per message the client already knows about and is +// still present upstream; the client diffs these against its cached flags to +// detect read/unread (and other flag) transitions. +type FlagUpdate struct { + UID uint32 `json:"uid"` + Flags []string `json:"flags"` +} + +// MailboxDelta is the incremental-sync payload for one mailbox (proposal §6). +// +// Because go-imap v1 exposes no CONDSTORE/MODSEQ, the delta is computed from +// UIDs and flags rather than a MODSEQ token: the client sends the UIDs it +// already has, and the server returns only what changed — +// - Added: full envelopes for UIDs newer than the client's highest known UID. +// - Flags: current flags for known UIDs still present (client diffs locally). +// - Removed: known UIDs that have since been expunged/moved away. +// +// UIDVALIDITY is the cache-coherence guard: if it differs from the client's +// stored value the cached UIDs are meaningless, so Resync is set and the client +// must discard its cache and refetch the folder from scratch. +type MailboxDelta struct { + UIDValidity uint32 `json:"uidvalidity"` + Total uint32 `json:"total"` + Resync bool `json:"resync"` + Added []Envelope `json:"added"` + Flags []FlagUpdate `json:"flags"` + Removed []uint32 `json:"removed"` +} + // AttachmentMeta describes an attachment without including its bytes. type AttachmentMeta struct { ID string `json:"id"` diff --git a/server/internal/session/store.go b/server/internal/session/store.go index e83b409..c795e4b 100644 --- a/server/internal/session/store.go +++ b/server/internal/session/store.go @@ -158,16 +158,17 @@ func (s *Store) Get(id string) (*Session, bool) { } // IMAPFor returns the live IMAP client for the session, transparently -// reconnecting from sealed credentials if the previous connection was dropped. +// reconnecting from sealed credentials if there is no connection yet. +// +// We deliberately do NOT probe the existing connection with a NOOP here: every +// Client operation already calls ensureLive, which NOOPs and self-heals from the +// stored credentials on failure. Probing here too would add a full extra +// round-trip to the IMAP server (~one RTT) on every request for no benefit. func (s *Store) IMAPFor(ctx context.Context, sess *Session) (*imap.Client, error) { sess.mu.Lock() defer sess.mu.Unlock() if sess.imap != nil { - if err := sess.imap.Ping(ctx); err == nil { - return sess.imap, nil - } - _ = sess.imap.Close() - sess.imap = nil + return sess.imap, nil } creds, err := s.sealer.Open(sess.sealed) if err != nil { diff --git a/src/nonview/api/endpoints.ts b/src/nonview/api/endpoints.ts index d5b6fe6..3f1adc1 100644 --- a/src/nonview/api/endpoints.ts +++ b/src/nonview/api/endpoints.ts @@ -6,6 +6,7 @@ import type { APIClient } from "./client"; import type { LoginRequest, LoginResponse, + MailboxDelta, MailboxListResponse, Message, MessageListResponse, @@ -42,6 +43,25 @@ export const mailboxes = { }`; return client.get(path, signal); }, + // Incremental sync (proposal §6): given the client's cached UIDVALIDITY and + // the UIDs it already holds, return only what changed. `known` is sent as a + // comma-separated UID list; the server derives the "since" watermark from it. + changes( + client: APIClient, + mailbox: string, + opts: { uidvalidity?: number; known?: number[]; limit?: number } = {}, + signal?: AbortSignal, + ) { + const params = new URLSearchParams(); + if (opts.uidvalidity) params.set("uidvalidity", String(opts.uidvalidity)); + if (opts.limit) params.set("limit", String(opts.limit)); + if (opts.known && opts.known.length) params.set("known", opts.known.join(",")); + const qs = params.toString(); + const path = `${v1}/mailboxes/${encodeURIComponent(mailbox)}/changes${ + qs ? `?${qs}` : "" + }`; + return client.get(path, signal); + }, }; export const messages = { diff --git a/src/nonview/api/types.ts b/src/nonview/api/types.ts index 90fb1b1..0c9d149 100644 --- a/src/nonview/api/types.ts +++ b/src/nonview/api/types.ts @@ -79,6 +79,29 @@ export interface MessageListResponse { next_before?: number; // Total messages in the mailbox (independent of the page), for "1–50 of N". total?: number; + // The mailbox's UIDVALIDITY, persisted so a later delta sync can detect a + // cache-invalidating change upstream. + uidvalidity?: number; +} + +// FlagUpdate is the current flag set for one known message (see MailboxDelta). +export interface FlagUpdate { + uid: number; + flags: string[]; +} + +// MailboxDelta is the incremental-sync payload from GET .../changes. Mirrors +// server/internal/mail/types.go MailboxDelta. `added` are full envelopes for +// genuinely-new messages; `flags` are current flags for known messages still +// present (the client diffs them); `removed` are known UIDs now gone. When +// `resync` is true the client must discard its cache and refetch the folder. +export interface MailboxDelta { + uidvalidity: number; + total: number; + resync: boolean; + added: Envelope[] | null; + flags: FlagUpdate[] | null; + removed: number[] | null; } export interface APIErrorBody { diff --git a/src/nonview/cache/db.ts b/src/nonview/cache/db.ts index c7617a9ba5bf5db3a8c3f07d70cc2bcc7d35c9d3..d19e9de40a25806c534288b0118666fc442a3cd1 100644 GIT binary patch delta 1126 zcmZuwPiqrF6sOYGl0Zybm8yrnwh9S0+t`ChDwIM!1T7YA3xWq{ciuLGo1JB5Hfd-} zj(&hS_yIh4@lYuA=Eb8I1wVj-H^H-b^UZcsQ|MljdHd$~-tW);sh-?F!m<9+5^SQX zg)-u(K=!-%_8{p{sGLlnTUM3hk72A>{dN^l?vWrk>JAgUV+eHS_sXJ zwao%S1}tb{Es!l#7zYTuDO0=wBFwrpstyfPn8>~|8W?a9hOX5BL8iEvwPhgO!EHC! zsY{_-Eka;1&l>Wj+o?9-BjJT;XfB6P$u{UfF*QKMnX zxZ4VK3p#?ig)lMa@x+P+RDr7|QIZ3yHbZNf;@EUs! z!c{bL1!x`%0+|xKyChzO)Q}e%_UY)%ES-nMY(Sj0eN-#1oN#9B*4Ud{ka^<<%r#(( z`4K)GOg#QNRvzhto6}ha#<_;%LQ6xba$?SB57u!{AbF*fKn--rM53`7mT4*Cu9tkt z-D_HeG?1!GPC^<7Ef(*R)GSa^llhOyeKJ)G8H1m;6EA~tdV6`fvRw7FktA}(N#q{4 zhNIMgc~Wd~pvv*L@z2F_jh;-^Jdk#z?AfEsrAGzl;4|loi`;2STts%(o;25+TQA&} zAY-K8EYy{{3tsFh*?bN?H1`*HH-C8*nz^1{SmOZ&Y% z(|7(=tIivsFql-u?t57Eh75$&Rj9j;Lt2g!m?h$@`}9rWFk_KNGb1w0K>FD?LPLV5ghsyR~RzBd4}@220{zthG4auu%r{s)FBeaZj; delta 34 scmV+-0Nww#Huov8Wd)Ne2X?c@2L%DMq6xhVvy2!B0h6H_m6LfIz3U Promise; prevPage: (role: string) => Promise; refresh: () => Promise; + // Delta-sync just one folder by role ("inbox" | "sent" | "drafts" | "trash"). + refreshFolder: (role: string) => Promise; getThread: (id: string) => Thread | undefined; getMessages: (threadId: string) => Promise; // Cache-first body read; returns null if nothing is cached for the thread. @@ -304,6 +308,8 @@ export const DataProvider: React.FC = ({ children }) => { const fresh = msgs.map((e) => envelopeToThread(e, mailbox || "")); set(fresh); void writeThreads(account, role, fresh); + // Persist UIDVALIDITY so a later refresh can sync via a delta. + if (val.uidvalidity) void writeUidValidity(account, role, val.uidvalidity); // Reset to page 0; cursor for page 1 is this page's next_before. pageCursors.current[role] = [undefined, val.next_before]; setPage((p) => ({ ...p, [role]: 0 })); @@ -353,7 +359,7 @@ export const DataProvider: React.FC = ({ children }) => { return () => ctrl.abort(); }, [isAuthenticated, loadAll, hydrateFromCache]); - const refresh = useCallback(() => loadAll(), [loadAll]); + // `refresh` is defined below, after the pagination helpers it depends on. // Maps a role to its current thread array's state setter. const setterForRole = useCallback( @@ -401,7 +407,10 @@ export const DataProvider: React.FC = ({ children }) => { cursors[target + 1] = resp.next_before; pageCursors.current[role] = cursors; // Only page 0 is mirrored to the offline cache (the "newest" view). - if (target === 0) void writeThreads(account, role, fresh); + if (target === 0) { + void writeThreads(account, role, fresh); + if (resp.uidvalidity) void writeUidValidity(account, role, resp.uidvalidity); + } } catch (err) { if ((err as { name?: string })?.name !== "AbortError") { setError((err as Error).message || "Failed to load page"); @@ -438,6 +447,128 @@ export const DataProvider: React.FC = ({ children }) => { [page, pageLoading, fetchPage], ); + // Incremental sync for one folder (proposal §6). Diffs the cached page-0 view + // against the server and applies only added/removed/flag-changed messages, + // instead of re-listing the whole page. Falls back to a full page-0 fetch when + // there's no baseline to diff against, or when the server signals a resync + // (UIDVALIDITY changed → cached UIDs are stale). + const syncRole = useCallback( + async (role: string): Promise => { + const mailbox = rolesRef.current[role]; + const set = setterForRole(role); + if (!mailbox || !set) return; + + const cached = await readThreads(account, role); + const known = cached.map((t) => t.uid).filter((u) => u > 0); + const uidvalidity = await readUidValidity(account, role); + // Cold folder or unknown UIDVALIDITY → nothing to diff; do a full fetch. + if (!uidvalidity || known.length === 0) { + await fetchPage(role, 0, undefined); + return; + } + + const delta = await mailboxesAPI.changes(apiClient, mailbox, { + uidvalidity, + known, + limit: PAGE_SIZE, + }); + if (delta.resync) { + await fetchPage(role, 0, undefined); + return; + } + + // Reconcile against the cached baseline, keyed on UID and applied + // idempotently so repeated syncs converge to the same result. + let next = cached.slice(); + + if (delta.removed?.length) { + const gone = new Set(delta.removed); + next = next.filter((t) => !gone.has(t.uid)); + } + if (delta.flags?.length) { + const flagsByUid = new Map(delta.flags.map((f) => [f.uid, f.flags])); + next = next.map((t) => { + const fl = flagsByUid.get(t.uid); + if (!fl) return t; + const unread = fl.includes("\\Seen") ? 0 : 1; + return unread === t.unreadCount ? t : { ...t, unreadCount: unread }; + }); + } + if (delta.added?.length) { + const have = new Set(next.map((t) => t.uid)); + const fresh = delta.added + .filter((e) => !have.has(e.uid)) + .map((e) => envelopeToThread(e, mailbox)); + next = [...fresh, ...next]; + } + + // Keep the newest-first page-0 window. + next.sort( + (a, b) => + new Date(b.lastMessageTime).getTime() - + new Date(a.lastMessageTime).getTime(), + ); + if (next.length > PAGE_SIZE) next = next.slice(0, PAGE_SIZE); + + set(next); + void writeThreads(account, role, next); + void writeUidValidity(account, role, delta.uidvalidity); + setPage((p) => ({ ...p, [role]: 0 })); + setTotal((t) => ({ ...t, [role]: delta.total })); + // Cursor for page 1 is the oldest UID currently shown. + const oldestUid = next.reduce( + (min, t) => (t.uid > 0 && t.uid < min ? t.uid : min), + Number.MAX_SAFE_INTEGER, + ); + pageCursors.current[role] = [ + undefined, + oldestUid === Number.MAX_SAFE_INTEGER ? undefined : oldestUid, + ]; + }, + [apiClient, account, setterForRole, fetchPage], + ); + + // Delta-sync a single folder — the one the user is looking at. Scoping the + // refresh to one folder avoids contending four mailbox syncs on the session's + // single IMAP connection (they would otherwise serialise on its mutex, each + // paying a full SELECT). Falls back to a full load if roles aren't resolved + // yet (refresh raced ahead of the initial load). + const refreshFolder = useCallback( + async (role: string): Promise => { + if (!rolesRef.current[role]) { + await loadAll(); + return; + } + setError(null); + try { + await syncRole(role); + } catch (err) { + if ((err as { name?: string })?.name !== "AbortError") { + setError((err as Error).message || `Could not sync ${role}`); + } + } + }, + [loadAll, syncRole], + ); + + // Refresh every folder (e.g. a global "sync all"). Pages use refreshFolder for + // their own folder; this is kept for callers that genuinely want all of them. + const refresh = useCallback(async (): Promise => { + if (Object.keys(rolesRef.current).length === 0) { + await loadAll(); + return; + } + setError(null); + const roles = ["inbox", "sent", "drafts", "trash"]; + const results = await Promise.allSettled(roles.map((role) => syncRole(role))); + const failed = results + .map((s, i) => ({ s, role: roles[i] })) + .filter(({ s }) => s.status === "rejected"); + if (failed.length > 0) { + setError(`Could not sync: ${failed.map(({ role }) => role).join(", ")}`); + } + }, [loadAll, syncRole]); + const allThreads = useMemo( () => [...threads, ...sentThreads, ...drafts, ...trashedThreads], [threads, sentThreads, drafts, trashedThreads], @@ -640,6 +771,7 @@ export const DataProvider: React.FC = ({ children }) => { nextPage, prevPage, refresh, + refreshFolder, getThread, getMessages, getCachedMessages, diff --git a/src/view/moles/ThreadList.tsx b/src/view/moles/ThreadList.tsx index 31482a0..82367d2 100644 --- a/src/view/moles/ThreadList.tsx +++ b/src/view/moles/ThreadList.tsx @@ -1,7 +1,8 @@ -import React, { useEffect, useRef } from "react"; +import React, { useEffect, useRef, useState } from "react"; import { Box, CircularProgress, IconButton, Typography } from "@mui/material"; import ChevronLeftIcon from "@mui/icons-material/ChevronLeft"; import ChevronRightIcon from "@mui/icons-material/ChevronRight"; +import RefreshIcon from "@mui/icons-material/Refresh"; import { useNavigate } from "react-router-dom"; import { useVirtualizer } from "@tanstack/react-virtual"; import ThreadListItem from "./ThreadListItem"; @@ -25,9 +26,23 @@ const ThreadList = ({ onNext = undefined, onPrev = undefined, pageLoading = false, + // Optional delta-sync trigger. When provided, a refresh button is shown in the + // pager bar; it fetches only what changed since the last sync (proposal §6). + onRefresh = undefined, }) => { const navigate = useNavigate(); const parentRef = useRef(null); + const [refreshing, setRefreshing] = useState(false); + + const handleRefresh = async () => { + if (!onRefresh || refreshing) return; + setRefreshing(true); + try { + await onRefresh(); + } finally { + setRefreshing(false); + } + }; // Jump back to the top of the list whenever the page changes, so a new page // starts at its first message rather than wherever the previous one scrolled. @@ -74,6 +89,8 @@ const ThreadList = ({ // Pager math. start/end are 1-based and reflect the rows actually shown. const showPager = !!(onNext || onPrev) && total > 0; + // The top bar appears for the pager and/or the refresh button. + const showBar = showPager || !!onRefresh; const start = page * pageSize + 1; const end = page * pageSize + threads.length; const canPrev = page > 0 && !pageLoading; @@ -88,7 +105,7 @@ const ThreadList = ({ backgroundColor: "background.paper", }} > - {showPager && ( + {showBar && ( - {pageLoading && } - - {start.toLocaleString()}–{end.toLocaleString()} of{" "} - {total.toLocaleString()} - - onPrev && onPrev()} - > - - - onNext && onNext()} - > - - + {onRefresh && ( + + {refreshing ? : } + + )} + {showPager && ( + <> + {pageLoading && } + + {start.toLocaleString()}–{end.toLocaleString()} of{" "} + {total.toLocaleString()} + + onPrev && onPrev()} + > + + + onNext && onNext()} + > + + + + )} )} diff --git a/src/view/pages/InboxPage.tsx b/src/view/pages/InboxPage.tsx index 7ed3dc1..f385769 100644 --- a/src/view/pages/InboxPage.tsx +++ b/src/view/pages/InboxPage.tsx @@ -8,7 +8,7 @@ import FloatingActionButton from "../atoms/FloatingActionButton"; import { useData } from "../../nonview/core/DataContext"; function InboxPage() { - const { threads, loading, page, total, pageSize, pageLoading, nextPage, prevPage } = + const { threads, loading, page, total, pageSize, pageLoading, nextPage, prevPage, refreshFolder } = useData(); const navigate = useNavigate(); const [searchQuery, setSearchQuery] = useState(""); @@ -42,6 +42,7 @@ function InboxPage() { pageLoading={pageLoading.inbox} onNext={searchQuery ? undefined : () => nextPage("inbox")} onPrev={searchQuery ? undefined : () => prevPage("inbox")} + onRefresh={searchQuery ? undefined : () => refreshFolder("inbox")} /> nextPage("sent")} onPrev={searchQuery ? undefined : () => prevPage("sent")} + onRefresh={searchQuery ? undefined : () => refreshFolder("sent")} /> ); diff --git a/src/view/pages/TrashPage.tsx b/src/view/pages/TrashPage.tsx index 35f3541..bc85621 100644 --- a/src/view/pages/TrashPage.tsx +++ b/src/view/pages/TrashPage.tsx @@ -14,6 +14,7 @@ function TrashPage() { pageLoading, nextPage, prevPage, + refreshFolder, } = useData(); const [searchQuery, setSearchQuery] = useState(""); @@ -46,6 +47,7 @@ function TrashPage() { pageLoading={pageLoading.trash} onNext={searchQuery ? undefined : () => nextPage("trash")} onPrev={searchQuery ? undefined : () => prevPage("trash")} + onRefresh={searchQuery ? undefined : () => refreshFolder("trash")} /> );