diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index aa83bbac1..cc7f73b5e 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -1406,6 +1406,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { const bindings = pushBindingsRef.current; if (!isAuthenticated || !client) { releasePushBindings(bindings); + setPushConnected(false); return; } reconcilePushBindings({ @@ -1439,6 +1440,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { if (bindings.size > 0) { setPushConnected(true); debug.log('push', `[Push] Push notifications enabled for ${bindings.size} account(s)`); + } else { + setPushConnected(false); } }, [isAuthenticated, client, activeAccountId, connectedAccountsSignature, connectedAccountsRevision, setPushConnected]); diff --git a/stores/__tests__/email-store-plugin-sync-hooks.test.ts b/stores/__tests__/email-store-plugin-sync-hooks.test.ts new file mode 100644 index 000000000..b9fa8689b --- /dev/null +++ b/stores/__tests__/email-store-plugin-sync-hooks.test.ts @@ -0,0 +1,155 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useEmailStore } from '../email-store'; +import { useAuthStore } from '../auth-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils'; +import { clearAllHooks, emailHooks } from '@/lib/plugin-hooks'; +import { emailSummary } from '@/lib/plugin-projection'; +import type { Email, Mailbox, StateChange } from '@/lib/jmap/types'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; + +// Sync hooks: new mail in the Inbox, live-update connection, and the +// syncing/idle/error cycle of a push or poll. + +function makeMailbox(overrides: Partial): Mailbox { + return { + id: 'inbox', name: 'Inbox', sortOrder: 0, totalEmails: 0, unreadEmails: 0, + totalThreads: 0, unreadThreads: 0, isSubscribed: true, isShared: false, ...overrides, + } as Mailbox; +} + +function makeEmail(id: string, overrides: Partial = {}): Email { + return { + id, threadId: `t-${id}`, subject: id, receivedAt: '2026-01-01T00:00:00Z', + keywords: {}, mailboxIds: { inbox: true }, from: [{ name: 'A', email: 'a@x.test' }], ...overrides, + } as Email; +} + +const serverEmails: Record = { + arrived: makeEmail('arrived'), + draft: makeEmail('draft', { keywords: { $draft: true } }), + sent: makeEmail('sent', { mailboxIds: { sent: true } }), + movedIn: makeEmail('movedIn'), + old: makeEmail('old'), +}; + +let client: IJMAPClient; + +function change(state: string): StateChange { + return { '@type': 'StateChange', changed: { me: { Email: state } } } as StateChange; +} + +/** Let the void-ed new-mail check finish. */ +const settle = () => new Promise(resolve => setTimeout(resolve, 20)); + +beforeEach(() => { + clearAllHooks(); + client = { + getAccountId: () => 'me', + getEmailChanges: vi.fn().mockResolvedValue({ + oldState: 's0', newState: 's1', hasMoreChanges: false, + created: ['arrived', 'draft', 'sent'], updated: ['movedIn'], destroyed: [], updatedProperties: null, + }), + getSomeEmails: vi.fn().mockImplementation(async (ids: string[]) => ids.map(id => serverEmails[id]).filter(Boolean)), + getEmails: vi.fn().mockResolvedValue({ emails: [], hasMore: false, total: 0, state: 's1' }), + getMaxObjectsInGet: () => 500, + getThreads: vi.fn().mockResolvedValue([]), + } as unknown as IJMAPClient; + useAuthStore.setState({ activeAccountId: 'acct', getClientForAccount: () => client as never } as never); + useSettingsStore.setState({ emailsPerPage: 50, emailKeywords: [] } as never); + useEmailStore.setState({ + mailboxes: [makeMailbox({ id: 'inbox', role: 'inbox' }), makeMailbox({ id: 'sent', role: 'sent' })], + accountMailboxes: {}, + viewingAccountId: null, + selectedMailbox: 'inbox', + selectedKeyword: null, + isUnifiedView: false, + isScheduledView: false, + crossView: null, + searchQuery: '', + searchFilters: { ...DEFAULT_SEARCH_FILTERS }, + emails: [], + emailListSync: null, + isPushConnected: false, + }); +}); + +afterEach(() => clearAllHooks()); + +describe('onNewEmailReceived', () => { + it('reports each message created in the Inbox, and not drafts, other folders or moves', async () => { + // A baseline other tests have not advanced yet. + useEmailStore.setState({ emailListSync: { state: 's0', accountId: 'me', mailboxId: 'inbox' } }); + vi.mocked(client.getEmailChanges!).mockResolvedValue({ + oldState: 's0', newState: 's1', hasMoreChanges: false, + created: ['arrived', 'draft', 'sent'], updated: ['movedIn'], destroyed: [], updatedProperties: null, + }); + const received = vi.fn(); + emailHooks.onNewEmailReceived.register('t', received); + + await useEmailStore.getState().handleStateChange(change('s1'), client); + await settle(); + + expect(received).toHaveBeenCalledTimes(1); + expect(received).toHaveBeenCalledWith(emailSummary(serverEmails.arrived)); + expect(client.getEmailChanges).toHaveBeenCalledWith('s0', undefined, expect.any(Number)); + + // The same state again reports nothing new. + await useEmailStore.getState().handleStateChange(change('s1'), client); + await settle(); + expect(received).toHaveBeenCalledTimes(1); + }); + + it('reports nothing for what was there before the first sync state (the initial load)', async () => { + const received = vi.fn(); + emailHooks.onNewEmailReceived.register('t', received); + // A fresh account: no list baseline, so the first push only sets one. + useAuthStore.setState({ activeAccountId: 'fresh' } as never); + const freshClient = { ...client, getAccountId: () => 'fresh' } as IJMAPClient; + vi.mocked(client.getEmailChanges!).mockResolvedValue({ + oldState: 'f0', newState: 'f1', hasMoreChanges: false, + created: ['old'], updated: [], destroyed: [], updatedProperties: null, + }); + + await useEmailStore.getState().handleStateChange( + { '@type': 'StateChange', changed: { fresh: { Email: 'f1' } } } as StateChange, freshClient, + ); + await settle(); + + expect(received).not.toHaveBeenCalled(); + }); +}); + +describe('onSyncStateChange', () => { + it('goes syncing then idle around a state change', async () => { + const states = vi.fn(); + emailHooks.onSyncStateChange.register('t', states); + + await useEmailStore.getState().handleStateChange({ '@type': 'StateChange', changed: {} } as StateChange, client); + + expect(states.mock.calls).toEqual([[{ state: 'syncing' }], [{ state: 'idle' }]]); + }); + + it('reports an error when handling the change failed', async () => { + const states = vi.fn(); + emailHooks.onSyncStateChange.register('t', states); + const broken = { ...client, getAccountId: () => { throw new Error('session gone'); } } as IJMAPClient; + + await useEmailStore.getState().handleStateChange({ '@type': 'StateChange', changed: {} } as StateChange, broken); + + expect(states.mock.calls).toEqual([[{ state: 'syncing' }], [{ state: 'error', error: 'session gone' }]]); + }); +}); + +describe('onPushConnectionChange', () => { + it('reports when live updates connect and drop, not repeats', () => { + const connection = vi.fn(); + emailHooks.onPushConnectionChange.register('t', connection); + + useEmailStore.getState().setPushConnected(true); + useEmailStore.getState().setPushConnected(true); + useEmailStore.getState().setPushConnected(false); + + expect(connection.mock.calls).toEqual([[{ connected: true }], [{ connected: false }]]); + }); +}); diff --git a/stores/email-store.ts b/stores/email-store.ts index 6ab2e5802..312b566b5 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -8,6 +8,7 @@ import { orderKeywords, placeHeldRows, type HeldKeywords, type SortLevel } from import { SearchFilters, DEFAULT_SEARCH_FILTERS, buildJMAPFilter, isFilterEmpty } from "@/lib/jmap/search-utils"; import { emailHooks } from "@/lib/plugin-hooks"; import { allowEmailMove, emitEmailMove, keywordChangeLists, mailboxToView, quotaToView } from "@/lib/plugin-mail-hooks"; +import { emailSummary } from "@/lib/plugin-projection"; import { resolveThreadRoute } from "@/lib/thread-routing"; import { emailKeyFor, threadKeyFor, threadIdFromKey, KEYWORD_PREFIX, KEYWORD_PREFIX_LEGACY } from "@/lib/thread-utils"; import type { ExternalSearchResult } from "@/lib/plugin-types"; @@ -740,6 +741,68 @@ async function applyEmailDeltaNow(client: IJMAPClient, newState: string, get: St return true; } +// ─── New mail for plugins (onNewEmailReceived) ─────────────────────────────── +// +// A message is new mail when Email/changes reports it as created and it sits +// in the Inbox. A move into the Inbox (undo spam, a drag, unarchive) keeps +// its id and shows up as updated, and the initial load is the baseline, so +// neither is reported. The check runs on its own baseline, independent of +// whichever folder or view is open. + +/** Email state per JMAP account up to which new mail was reported. */ +const newMailBaselines = new Map(); +/** Recently reported ids, so an overlapping delta never reports one twice. */ +const reportedNewMail: string[] = []; +let newMailQueue: Promise = Promise.resolve(); +const NEW_MAIL_MAX_PAGES = 5; + +function reportNewInboxMail(client: IJMAPClient, accountId: string, pushedState: string, seedState: string | undefined, get: StoreGet): Promise { + const run = async () => { + const since = newMailBaselines.get(accountId) ?? seedState; + // Nobody listens: keep the baseline current so a plugin that starts + // listening later is not handed old mail. + if (emailHooks.onNewEmailReceived.size === 0 || !client.getEmailChanges || !since) { + newMailBaselines.set(accountId, pushedState); + return; + } + if (since === pushedState) return; + const created: string[] = []; + let state = since; + for (let page = 0; page < NEW_MAIL_MAX_PAGES; page++) { + const delta = await client.getEmailChanges(state, undefined, EMAIL_DELTA_MAX_CHANGES); + if (!delta) { + // The baseline is too old for the server; start over from here. + newMailBaselines.set(accountId, pushedState); + return; + } + created.push(...delta.created); + state = delta.newState; + if (!delta.hasMoreChanges) break; + } + newMailBaselines.set(accountId, state); + const fresh = created.filter(id => !reportedNewMail.includes(id)); + if (fresh.length === 0) return; + const inbox = get().mailboxes.find(mb => mb.role === 'inbox' && !mb.isShared); + if (!inbox) return; + const inboxIds = [inbox.id, inbox.originalId].filter((id): id is string => !!id); + const emails = await client.getSomeEmails(fresh); + for (const email of emails) { + if (email.keywords?.$draft || !inboxIds.some(id => email.mailboxIds?.[id])) continue; + reportedNewMail.push(email.id); + if (reportedNewMail.length > 500) reportedNewMail.shift(); + void emailHooks.onNewEmailReceived.emit(emailSummary(email)); + } + }; + const next = newMailQueue.then(run, run).catch((error) => { + console.error('Failed to check for new mail:', error); + }); + newMailQueue = next; + return next; +} + +/** Overlapping state-change runs share one syncing/idle cycle (onSyncStateChange). */ +let syncRuns = 0; + function resolveActionClient(passedClient: IJMAPClient): IJMAPClient { const viewingId = useEmailStore.getState().viewingAccountId; if (!viewingId) return passedClient; @@ -4632,6 +4695,9 @@ export const useEmailStore = create((set, get) => ({ }, handleStateChange: async (change, client) => { + syncRuns += 1; + if (syncRuns === 1) void emailHooks.onSyncStateChange.emit({ state: 'syncing' }); + let syncError: string | null = null; try { // Update last push update timestamp set({ lastPushUpdate: Date.now() }); @@ -4639,6 +4705,18 @@ export const useEmailStore = create((set, get) => ({ // Get the current account ID from the client (assuming primary account) const accountId = client.getAccountId(); + // New mail for plugins. Read the list's baseline before the delta + // below moves it past the new messages. + const pushedEmailState = change.changed[accountId]?.Email; + if (pushedEmailState) { + const listSync = get().emailListSync; + void reportNewInboxMail( + client, accountId, pushedEmailState, + listSync?.accountId === accountId ? listSync.state : undefined, + get, + ); + } + // Changes may arrive for the client's primary account OR a delegated // shared/group owner it has access to. Active-account *view* concerns // (current email list, scheduled, calendar, filters) key off the primary @@ -4735,9 +4813,15 @@ export const useEmailStore = create((set, get) => ({ } } catch (error) { console.error('Failed to handle state change:', error); - set({ - error: error instanceof Error ? error.message : "Failed to handle push notification" - }); + syncError = error instanceof Error ? error.message : "Failed to handle push notification"; + set({ error: syncError }); + } finally { + syncRuns -= 1; + if (syncError !== null) { + void emailHooks.onSyncStateChange.emit({ state: 'error', error: syncError }); + } else if (syncRuns === 0) { + void emailHooks.onSyncStateChange.emit({ state: 'idle' }); + } } }, @@ -6274,6 +6358,9 @@ useEmailStore.subscribe((state, prev) => { && (state.quota.used !== prev.quota?.used || state.quota.total !== prev.quota?.total)) { void emailHooks.onQuotaChange.emit(quotaToView(state.quota)); } + if (state.isPushConnected !== prev.isPushConnected) { + void emailHooks.onPushConnectionChange.emit({ connected: state.isPushConnected }); + } }); // Keep the unified-section badges in lockstep with the live per-account mailbox