feat: tell plugins about new inbox mail, sync progress and the live-update connection

This commit is contained in:
Linus Rath
2026-10-14 03:56:37 +02:00
parent 77d8b604ae
commit 7323310b21
3 changed files with 248 additions and 3 deletions
+3
View File
@@ -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]);
@@ -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>): 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> = {}): 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<string, Email> = {
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 }]]);
});
});
+90 -3
View File
@@ -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<string, string>();
/** Recently reported ids, so an overlapping delta never reports one twice. */
const reportedNewMail: string[] = [];
let newMailQueue: Promise<void> = Promise.resolve();
const NEW_MAIL_MAX_PAGES = 5;
function reportNewInboxMail(client: IJMAPClient, accountId: string, pushedState: string, seedState: string | undefined, get: StoreGet): Promise<void> {
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<EmailStore>((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<EmailStore>((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<EmailStore>((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