mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 16:13:15 +00:00
feat: tell plugins about new inbox mail, sync progress and the live-update connection
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user