mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 08:15:55 +00:00
feat: tell plugins when messages are deleted, moved, archived, marked or tagged, and let them stop it
This commit is contained in:
@@ -3667,8 +3667,14 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
if (useEmailStore.getState().emails.some((e) => e.id === target.id)) {
|
||||
await markAsRead(client, target.id, true);
|
||||
} else {
|
||||
// Not in the store's list, so the store action can't ask plugins.
|
||||
if (emailHooks.onBeforeEmailReadStateChange.size > 0
|
||||
&& !(await emailHooks.onBeforeEmailReadStateChange.intercept({ emailIds: [target.id], read: true }))) {
|
||||
return;
|
||||
}
|
||||
const { threadClient, accountId } = mvConversationRoute(target, client);
|
||||
await threadClient.markAsRead(target.id, true, accountId);
|
||||
void emailHooks.onEmailReadStateChange.emit({ emailIds: [target.id], read: true });
|
||||
}
|
||||
patchMvConversation(target.id, (e) => ({ ...e, keywords: { ...e.keywords, $seen: true } }));
|
||||
} catch (error) {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useProTabStore, type ProFolderTabData } from "@/stores/pro-tab-store";
|
||||
import { useDeviceDetection } from "@/hooks/use-media-query";
|
||||
import { usePaneId } from "@/hooks/use-pane-context";
|
||||
import type { Email, Mailbox } from "@/lib/jmap/types";
|
||||
import { emailHooks } from "@/lib/plugin-hooks";
|
||||
|
||||
interface ProFolderTabBodyProps {
|
||||
tabId: string;
|
||||
@@ -118,16 +119,24 @@ export function ProFolderTabBody({ tabId, data }: ProFolderTabBodyProps) {
|
||||
}
|
||||
}, [hasMore, isLoadingMore, isLoading, loadPage, emails.length]);
|
||||
|
||||
const markRowAsRead = useCallback((email: Email) => {
|
||||
const markRowAsRead = useCallback(async (email: Email) => {
|
||||
if (!client || email.keywords?.$seen) return;
|
||||
// Straight to the client: the store's markAsRead only serves emails in
|
||||
// its own (global) list, which this tab's rows are not part of. So the
|
||||
// plugin hooks the store action fires are fired here.
|
||||
if (emailHooks.onBeforeEmailReadStateChange.size > 0
|
||||
&& !(await emailHooks.onBeforeEmailReadStateChange.intercept({ emailIds: [email.id], read: true }))) {
|
||||
return;
|
||||
}
|
||||
setEmails((prev) => prev.map((e) => e.id === email.id
|
||||
? { ...e, keywords: { ...e.keywords, $seen: true } }
|
||||
: e));
|
||||
// Straight to the client: the store's markAsRead only serves emails in
|
||||
// its own (global) list, which this tab's rows are not part of.
|
||||
void client.markAsRead(email.id, true, jmapAccountId).catch((err) => {
|
||||
try {
|
||||
await client.markAsRead(email.id, true, jmapAccountId);
|
||||
void emailHooks.onEmailReadStateChange.emit({ emailIds: [email.id], read: true });
|
||||
} catch (err) {
|
||||
console.error('Mark as read failed:', err);
|
||||
});
|
||||
}
|
||||
}, [client, jmapAccountId]);
|
||||
|
||||
const openAsEmailTab = useCallback((email: Email, reuseReader: boolean) => {
|
||||
@@ -143,7 +152,7 @@ export function ProFolderTabBody({ tabId, data }: ProFolderTabBodyProps) {
|
||||
}, [data.mailboxId, paneId, t]);
|
||||
|
||||
const handleRowClick = useCallback((email: Email) => {
|
||||
markRowAsRead(email);
|
||||
void markRowAsRead(email);
|
||||
if (isMobile) {
|
||||
// No room for a reading pane - promote to a dedicated email tab.
|
||||
openAsEmailTab(email, true);
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
// Shared pieces for firing the mail plugin hooks (organize, folders, sync)
|
||||
// from the email store: the plain views plugins receive, and the rules that
|
||||
// decide which hooks a move fires.
|
||||
|
||||
import { emailHooks } from './plugin-hooks';
|
||||
import type { Mailbox } from './jmap/types';
|
||||
import type { MailboxView, QuotaView } from './plugin-types';
|
||||
|
||||
export function mailboxToView(mailbox: Mailbox): MailboxView {
|
||||
return {
|
||||
id: mailbox.id,
|
||||
name: mailbox.name,
|
||||
role: mailbox.role ?? null,
|
||||
totalEmails: mailbox.totalEmails ?? 0,
|
||||
unreadEmails: mailbox.unreadEmails ?? 0,
|
||||
parentId: mailbox.parentId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function quotaToView(quota: { used: number; total: number }): QuotaView {
|
||||
return {
|
||||
used: quota.used,
|
||||
total: quota.total,
|
||||
percentUsed: quota.total > 0 ? Math.round((quota.used / quota.total) * 1000) / 10 : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Bare email ids of a key-based selection (`scope:id` or `id`), each once. */
|
||||
export function idsFromSelectionKeys(keys: Iterable<string>): string[] {
|
||||
const ids = new Set<string>();
|
||||
for (const key of keys) ids.add(key.slice(key.lastIndexOf(':') + 1));
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
/** Keywords a `{ keyword: on/off }` change puts on and takes off. */
|
||||
export function keywordChangeLists(changes: Record<string, boolean>): { added: string[]; removed: string[] } {
|
||||
const added: string[] = [];
|
||||
const removed: string[] = [];
|
||||
for (const [keyword, on] of Object.entries(changes)) (on ? added : removed).push(keyword);
|
||||
return { added, removed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a folder is the Archive folder or one of its sub-folders: a year or
|
||||
* month archive files messages under the Archive folder, and those
|
||||
* sub-folders carry no role of their own.
|
||||
*/
|
||||
export function isArchiveFolder(mailboxId: string | null | undefined, mailboxes: Mailbox[]): boolean {
|
||||
if (!mailboxId) return false;
|
||||
const find = (id: string) => mailboxes.find((mb) => mb.id === id) ?? mailboxes.find((mb) => mb.originalId === id);
|
||||
const seen = new Set<string>();
|
||||
let current = find(mailboxId);
|
||||
while (current && !seen.has(current.id)) {
|
||||
if (current.role === 'archive') return true;
|
||||
seen.add(current.id);
|
||||
current = current.parentId ? find(current.parentId) : undefined;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export interface EmailMove {
|
||||
emailIds: string[];
|
||||
fromMailboxId: string | null;
|
||||
toMailboxId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask plugins before messages are moved. A move into the archive also asks
|
||||
* onBeforeEmailArchive. Returns false when a plugin stopped it.
|
||||
*/
|
||||
export async function allowEmailMove(move: EmailMove, mailboxes: Mailbox[]): Promise<boolean> {
|
||||
if (move.emailIds.length === 0) return true;
|
||||
if (emailHooks.onBeforeEmailMove.size > 0
|
||||
&& !(await emailHooks.onBeforeEmailMove.intercept({ ...move, emailIds: [...move.emailIds] }))) {
|
||||
return false;
|
||||
}
|
||||
if (emailHooks.onBeforeEmailArchive.size > 0
|
||||
&& isArchiveFolder(move.toMailboxId, mailboxes)
|
||||
&& !isArchiveFolder(move.fromMailboxId, mailboxes)
|
||||
&& !(await emailHooks.onBeforeEmailArchive.intercept({ emailIds: [...move.emailIds] }))) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell plugins messages were moved; a move into the archive is also an
|
||||
* archive, a move out of it an unarchive.
|
||||
*/
|
||||
export function emitEmailMove(move: EmailMove, mailboxes: Mailbox[]): void {
|
||||
if (move.emailIds.length === 0) return;
|
||||
void emailHooks.onAfterEmailMove.emit({ ...move, emailIds: [...move.emailIds] });
|
||||
const toArchive = isArchiveFolder(move.toMailboxId, mailboxes);
|
||||
const fromArchive = isArchiveFolder(move.fromMailboxId, mailboxes);
|
||||
if (toArchive && !fromArchive) {
|
||||
void emailHooks.onEmailArchive.emit({ emailIds: [...move.emailIds] });
|
||||
} else if (fromArchive && !toArchive) {
|
||||
void emailHooks.onEmailUnarchive.emit({ emailIds: [...move.emailIds], toMailboxId: move.toMailboxId });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
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 { clearAllHooks, emailHooks } from '@/lib/plugin-hooks';
|
||||
import { emailKeyFor } from '@/lib/thread-utils';
|
||||
import type { Email, Mailbox } from '@/lib/jmap/types';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
|
||||
// The organize hooks (delete, move, archive, spam, read, star, keywords) fire
|
||||
// from the store actions every button, menu and shortcut goes through.
|
||||
|
||||
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: { $seen: true },
|
||||
mailboxIds: { inbox: true },
|
||||
...overrides,
|
||||
} as Email;
|
||||
}
|
||||
|
||||
const mailboxes = [
|
||||
makeMailbox({ id: 'inbox', role: 'inbox' }),
|
||||
makeMailbox({ id: 'custom', name: 'Projects' }),
|
||||
makeMailbox({ id: 'archive', name: 'Archive', role: 'archive' }),
|
||||
makeMailbox({ id: 'archive-2026', name: '2026', parentId: 'archive' }),
|
||||
makeMailbox({ id: 'trash', name: 'Trash', role: 'trash' }),
|
||||
makeMailbox({ id: 'junk', name: 'Junk', role: 'junk' }),
|
||||
];
|
||||
|
||||
function makeClient(): IJMAPClient {
|
||||
return {
|
||||
getAccountId: () => 'me',
|
||||
moveToTrash: vi.fn().mockResolvedValue(undefined),
|
||||
deleteEmail: vi.fn().mockResolvedValue(undefined),
|
||||
batchDeleteEmails: vi.fn().mockResolvedValue(undefined),
|
||||
moveEmail: vi.fn().mockResolvedValue(undefined),
|
||||
batchMoveEmails: vi.fn().mockResolvedValue(undefined),
|
||||
batchArchiveEmails: vi.fn().mockResolvedValue(undefined),
|
||||
markAsRead: vi.fn().mockResolvedValue(undefined),
|
||||
batchMarkAsRead: vi.fn().mockResolvedValue(undefined),
|
||||
toggleStar: vi.fn().mockResolvedValue(undefined),
|
||||
batchUpdateKeywords: vi.fn().mockResolvedValue(undefined),
|
||||
setKeyword: vi.fn().mockResolvedValue(undefined),
|
||||
markAsSpam: vi.fn().mockResolvedValue(undefined),
|
||||
undoSpam: vi.fn().mockResolvedValue(undefined),
|
||||
getMailboxes: vi.fn().mockResolvedValue(mailboxes),
|
||||
getAllMailboxes: vi.fn().mockResolvedValue(mailboxes),
|
||||
getEmails: vi.fn().mockResolvedValue({ emails: [], hasMore: false, total: 0 }),
|
||||
getMaxObjectsInGet: () => 500,
|
||||
getThreads: vi.fn().mockResolvedValue([]),
|
||||
} as unknown as IJMAPClient;
|
||||
}
|
||||
|
||||
let client: IJMAPClient;
|
||||
|
||||
function openFolder(folderId: string, emails: Email[]) {
|
||||
useEmailStore.setState({
|
||||
mailboxes,
|
||||
accountMailboxes: {},
|
||||
viewingAccountId: null,
|
||||
selectedMailbox: folderId,
|
||||
selectedKeyword: null,
|
||||
isUnifiedView: false,
|
||||
unifiedRole: null,
|
||||
crossView: null,
|
||||
emails,
|
||||
selectedEmail: null,
|
||||
selectedEmailKeys: new Set(),
|
||||
threadEmailsCache: new Map(),
|
||||
processingReadStatus: new Set(),
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
clearAllHooks();
|
||||
client = makeClient();
|
||||
useAuthStore.setState({
|
||||
activeAccountId: 'acct',
|
||||
getClientForAccount: () => client as never,
|
||||
} as never);
|
||||
useSettingsStore.setState({ deleteAction: 'trash', permanentlyDeleteJunk: false, archiveMode: 'single' } as never);
|
||||
});
|
||||
|
||||
afterEach(() => clearAllHooks());
|
||||
|
||||
describe('delete hooks', () => {
|
||||
it('asks before moving to Trash and reports a non-permanent delete', async () => {
|
||||
openFolder('inbox', [makeEmail('e1')]);
|
||||
const order: string[] = [];
|
||||
const before = vi.fn(() => { order.push('before'); return true; });
|
||||
vi.mocked(client.moveToTrash).mockImplementation(async () => { order.push('server'); });
|
||||
const after = vi.fn();
|
||||
emailHooks.onBeforeEmailDelete.register('t', before);
|
||||
emailHooks.onAfterEmailDelete.register('t', after);
|
||||
|
||||
await useEmailStore.getState().deleteEmail(client, 'e1');
|
||||
|
||||
expect(before).toHaveBeenCalledWith({ emailIds: ['e1'], permanent: false });
|
||||
expect(order).toEqual(['before', 'server']);
|
||||
expect(after).toHaveBeenCalledTimes(1);
|
||||
expect(after).toHaveBeenCalledWith({ emailIds: ['e1'], permanent: false });
|
||||
});
|
||||
|
||||
it('reports permanent when the message is destroyed', async () => {
|
||||
openFolder('inbox', [makeEmail('e1')]);
|
||||
const after = vi.fn();
|
||||
emailHooks.onAfterEmailDelete.register('t', after);
|
||||
|
||||
await useEmailStore.getState().deleteEmail(client, 'e1', true);
|
||||
|
||||
expect(client.deleteEmail).toHaveBeenCalled();
|
||||
expect(after).toHaveBeenCalledWith({ emailIds: ['e1'], permanent: true });
|
||||
});
|
||||
|
||||
it('stops when a plugin says no', async () => {
|
||||
openFolder('inbox', [makeEmail('e1')]);
|
||||
const after = vi.fn();
|
||||
emailHooks.onBeforeEmailDelete.register('t', () => false);
|
||||
emailHooks.onAfterEmailDelete.register('t', after);
|
||||
|
||||
await useEmailStore.getState().deleteEmail(client, 'e1');
|
||||
|
||||
expect(client.moveToTrash).not.toHaveBeenCalled();
|
||||
expect(useEmailStore.getState().emails.map(e => e.id)).toEqual(['e1']);
|
||||
expect(after).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not report a delete the server refused', async () => {
|
||||
openFolder('inbox', [makeEmail('e1')]);
|
||||
vi.mocked(client.moveToTrash).mockRejectedValue(new Error('nope'));
|
||||
const after = vi.fn();
|
||||
emailHooks.onAfterEmailDelete.register('t', after);
|
||||
|
||||
await expect(useEmailStore.getState().deleteEmail(client, 'e1')).rejects.toThrow('nope');
|
||||
expect(after).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('batch delete in Trash is permanent', async () => {
|
||||
const a = makeEmail('a', { mailboxIds: { trash: true } });
|
||||
const b = makeEmail('b', { mailboxIds: { trash: true } });
|
||||
openFolder('trash', [a, b]);
|
||||
useEmailStore.setState({ selectedEmailKeys: new Set([emailKeyFor(a), emailKeyFor(b)]) });
|
||||
const before = vi.fn(() => true);
|
||||
const after = vi.fn();
|
||||
emailHooks.onBeforeEmailDelete.register('t', before);
|
||||
emailHooks.onAfterEmailDelete.register('t', after);
|
||||
|
||||
await useEmailStore.getState().batchDelete(client);
|
||||
|
||||
expect(before).toHaveBeenCalledWith({ emailIds: ['a', 'b'], permanent: true });
|
||||
expect(after).toHaveBeenCalledWith({ emailIds: ['a', 'b'], permanent: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('move and archive hooks', () => {
|
||||
it('asks before a move and reports it with source and destination', async () => {
|
||||
openFolder('inbox', [makeEmail('e1')]);
|
||||
const before = vi.fn(() => true);
|
||||
const after = vi.fn();
|
||||
const archived = vi.fn();
|
||||
emailHooks.onBeforeEmailMove.register('t', before);
|
||||
emailHooks.onAfterEmailMove.register('t', after);
|
||||
emailHooks.onEmailArchive.register('t', archived);
|
||||
|
||||
await useEmailStore.getState().moveToMailbox(client, 'e1', 'custom');
|
||||
|
||||
const payload = { emailIds: ['e1'], fromMailboxId: 'inbox', toMailboxId: 'custom' };
|
||||
expect(before).toHaveBeenCalledWith(payload);
|
||||
expect(after).toHaveBeenCalledTimes(1);
|
||||
expect(after).toHaveBeenCalledWith(payload);
|
||||
expect(archived).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stops a move a plugin refuses', async () => {
|
||||
openFolder('inbox', [makeEmail('e1')]);
|
||||
const after = vi.fn();
|
||||
emailHooks.onBeforeEmailMove.register('t', () => false);
|
||||
emailHooks.onAfterEmailMove.register('t', after);
|
||||
|
||||
await useEmailStore.getState().moveToMailbox(client, 'e1', 'custom');
|
||||
|
||||
expect(client.moveEmail).not.toHaveBeenCalled();
|
||||
expect(useEmailStore.getState().emails).toHaveLength(1);
|
||||
expect(after).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('treats a move into the archive (or a sub-folder of it) as archiving', async () => {
|
||||
openFolder('inbox', [makeEmail('e1'), makeEmail('e2')]);
|
||||
const archived = vi.fn();
|
||||
emailHooks.onEmailArchive.register('t', archived);
|
||||
|
||||
await useEmailStore.getState().moveToMailbox(client, 'e1', 'archive');
|
||||
await useEmailStore.getState().moveToMailbox(client, 'e2', 'archive-2026');
|
||||
|
||||
expect(archived.mock.calls).toEqual([[{ emailIds: ['e1'] }], [{ emailIds: ['e2'] }]]);
|
||||
});
|
||||
|
||||
it('lets onBeforeEmailArchive stop an archive', async () => {
|
||||
openFolder('inbox', [makeEmail('e1')]);
|
||||
emailHooks.onBeforeEmailArchive.register('t', () => false);
|
||||
|
||||
await useEmailStore.getState().moveToMailbox(client, 'e1', 'archive');
|
||||
|
||||
expect(client.moveEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports a move out of the archive as unarchiving', async () => {
|
||||
openFolder('archive', [makeEmail('e1', { mailboxIds: { archive: true } })]);
|
||||
const archived = vi.fn();
|
||||
const unarchived = vi.fn();
|
||||
emailHooks.onEmailArchive.register('t', archived);
|
||||
emailHooks.onEmailUnarchive.register('t', unarchived);
|
||||
|
||||
await useEmailStore.getState().moveToMailbox(client, 'e1', 'inbox');
|
||||
|
||||
expect(unarchived).toHaveBeenCalledWith({ emailIds: ['e1'], toMailboxId: 'inbox' });
|
||||
expect(archived).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('batch archive asks and reports once for the selection', async () => {
|
||||
const a = makeEmail('a');
|
||||
const b = makeEmail('b');
|
||||
openFolder('inbox', [a, b]);
|
||||
useEmailStore.setState({ selectedEmailKeys: new Set([emailKeyFor(a), emailKeyFor(b)]) });
|
||||
const before = vi.fn(() => true);
|
||||
const archived = vi.fn();
|
||||
emailHooks.onBeforeEmailArchive.register('t', before);
|
||||
emailHooks.onEmailArchive.register('t', archived);
|
||||
|
||||
await useEmailStore.getState().batchArchive(client);
|
||||
|
||||
expect(before).toHaveBeenCalledWith({ emailIds: ['a', 'b'] });
|
||||
expect(archived).toHaveBeenCalledTimes(1);
|
||||
expect(archived).toHaveBeenCalledWith({ emailIds: ['a', 'b'] });
|
||||
});
|
||||
|
||||
it('batch move reports nothing when the server refused it', async () => {
|
||||
const a = makeEmail('a');
|
||||
openFolder('inbox', [a]);
|
||||
useEmailStore.setState({ selectedEmailKeys: new Set([emailKeyFor(a)]) });
|
||||
vi.mocked(client.batchMoveEmails).mockRejectedValue(new Error('nope'));
|
||||
const after = vi.fn();
|
||||
emailHooks.onAfterEmailMove.register('t', after);
|
||||
|
||||
await useEmailStore.getState().batchMoveToMailbox(client, 'custom');
|
||||
|
||||
expect(after).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('spam hooks', () => {
|
||||
it('asks and reports mark-as-spam', async () => {
|
||||
openFolder('inbox', [makeEmail('e1')]);
|
||||
const before = vi.fn(() => true);
|
||||
const after = vi.fn();
|
||||
emailHooks.onBeforeEmailSpamToggle.register('t', before);
|
||||
emailHooks.onEmailSpamToggle.register('t', after);
|
||||
|
||||
await useEmailStore.getState().markAsSpam(client, 'e1');
|
||||
|
||||
expect(before).toHaveBeenCalledWith({ emailIds: ['e1'], spam: true });
|
||||
expect(after).toHaveBeenCalledWith({ emailIds: ['e1'], spam: true });
|
||||
});
|
||||
|
||||
it('stops mark-as-spam a plugin refuses', async () => {
|
||||
openFolder('inbox', [makeEmail('e1')]);
|
||||
emailHooks.onBeforeEmailSpamToggle.register('t', () => false);
|
||||
|
||||
await useEmailStore.getState().markAsSpam(client, 'e1');
|
||||
|
||||
expect(client.markAsSpam).not.toHaveBeenCalled();
|
||||
expect(useEmailStore.getState().emails).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('reports not-spam for a batch', async () => {
|
||||
const a = makeEmail('a', { mailboxIds: { junk: true } });
|
||||
const b = makeEmail('b', { mailboxIds: { junk: true } });
|
||||
openFolder('junk', [a, b]);
|
||||
const after = vi.fn();
|
||||
emailHooks.onEmailSpamToggle.register('t', after);
|
||||
|
||||
await useEmailStore.getState().batchUndoSpam(client, ['a', 'b']);
|
||||
|
||||
expect(after).toHaveBeenCalledWith({ emailIds: ['a', 'b'], spam: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('read, star and keyword hooks', () => {
|
||||
it('asks and reports a read-state change', async () => {
|
||||
openFolder('inbox', [makeEmail('e1', { keywords: {} })]);
|
||||
const before = vi.fn(() => true);
|
||||
const after = vi.fn();
|
||||
emailHooks.onBeforeEmailReadStateChange.register('t', before);
|
||||
emailHooks.onEmailReadStateChange.register('t', after);
|
||||
|
||||
await useEmailStore.getState().markAsRead(client, 'e1', true);
|
||||
|
||||
expect(before).toHaveBeenCalledWith({ emailIds: ['e1'], read: true });
|
||||
expect(after).toHaveBeenCalledWith({ emailIds: ['e1'], read: true });
|
||||
});
|
||||
|
||||
it('leaves the message unread when a plugin refuses', async () => {
|
||||
openFolder('inbox', [makeEmail('e1', { keywords: {} })]);
|
||||
emailHooks.onBeforeEmailReadStateChange.register('t', () => false);
|
||||
|
||||
await useEmailStore.getState().markAsRead(client, 'e1', true);
|
||||
|
||||
expect(client.markAsRead).not.toHaveBeenCalled();
|
||||
expect(useEmailStore.getState().emails[0].keywords.$seen).toBeUndefined();
|
||||
// A later attempt is not blocked by a stale in-progress marker.
|
||||
expect(useEmailStore.getState().processingReadStatus.size).toBe(0);
|
||||
});
|
||||
|
||||
it('reports a batch read-state change', async () => {
|
||||
const a = makeEmail('a', { keywords: {} });
|
||||
openFolder('inbox', [a]);
|
||||
useEmailStore.setState({ selectedEmailKeys: new Set([emailKeyFor(a)]) });
|
||||
const after = vi.fn();
|
||||
emailHooks.onEmailReadStateChange.register('t', after);
|
||||
|
||||
await useEmailStore.getState().batchMarkAsRead(client, true);
|
||||
|
||||
expect(after).toHaveBeenCalledWith({ emailIds: ['a'], read: true });
|
||||
});
|
||||
|
||||
it('asks and reports a star toggle', async () => {
|
||||
openFolder('inbox', [makeEmail('e1')]);
|
||||
const before = vi.fn(() => true);
|
||||
const after = vi.fn();
|
||||
emailHooks.onBeforeEmailStarToggle.register('t', before);
|
||||
emailHooks.onEmailStarToggle.register('t', after);
|
||||
|
||||
await useEmailStore.getState().toggleStar(client, 'e1');
|
||||
|
||||
expect(before).toHaveBeenCalledWith({ emailIds: ['e1'], starred: true });
|
||||
expect(after).toHaveBeenCalledWith({ emailIds: ['e1'], starred: true });
|
||||
});
|
||||
|
||||
it('stops a star toggle a plugin refuses', async () => {
|
||||
openFolder('inbox', [makeEmail('e1')]);
|
||||
emailHooks.onBeforeEmailStarToggle.register('t', () => false);
|
||||
|
||||
await useEmailStore.getState().toggleStar(client, 'e1');
|
||||
|
||||
expect(client.toggleStar).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports added and removed keywords', async () => {
|
||||
openFolder('inbox', [makeEmail('e1', { keywords: { $seen: true, '$label:old': true } })]);
|
||||
const before = vi.fn(() => true);
|
||||
const after = vi.fn();
|
||||
emailHooks.onBeforeEmailKeywordChange.register('t', before);
|
||||
emailHooks.onEmailKeywordChange.register('t', after);
|
||||
|
||||
await useEmailStore.getState().setEmailKeywords(client, 'e1', { $seen: true, '$label:new': true });
|
||||
|
||||
const payload = { emailIds: ['e1'], added: ['$label:new'], removed: ['$label:old'] };
|
||||
expect(before).toHaveBeenCalledWith(payload);
|
||||
expect(after).toHaveBeenCalledTimes(1);
|
||||
expect(after).toHaveBeenCalledWith(payload);
|
||||
});
|
||||
|
||||
it('stops a keyword change a plugin refuses', async () => {
|
||||
openFolder('inbox', [makeEmail('e1')]);
|
||||
const after = vi.fn();
|
||||
emailHooks.onBeforeEmailKeywordChange.register('t', () => false);
|
||||
emailHooks.onEmailKeywordChange.register('t', after);
|
||||
|
||||
await useEmailStore.getState().patchEmailKeywords(client, 'e1', { '$label:x': true });
|
||||
|
||||
expect(client.batchUpdateKeywords).not.toHaveBeenCalled();
|
||||
expect(after).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports a tag put on a selection', async () => {
|
||||
const a = makeEmail('a');
|
||||
const b = makeEmail('b');
|
||||
openFolder('inbox', [a, b]);
|
||||
useEmailStore.setState({ selectedEmailKeys: new Set([emailKeyFor(a), emailKeyFor(b)]) });
|
||||
const after = vi.fn();
|
||||
emailHooks.onEmailKeywordChange.register('t', after);
|
||||
|
||||
await useEmailStore.getState().batchSetTag(client, 'work', true);
|
||||
|
||||
expect(after).toHaveBeenCalledWith({ emailIds: ['a', 'b'], added: ['$label:work'], removed: [] });
|
||||
});
|
||||
});
|
||||
+175
-8
@@ -7,6 +7,7 @@ import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import { orderKeywords, placeHeldRows, type HeldKeywords, type SortLevel } from "@/lib/message-list-order";
|
||||
import { SearchFilters, DEFAULT_SEARCH_FILTERS, buildJMAPFilter, isFilterEmpty } from "@/lib/jmap/search-utils";
|
||||
import { emailHooks } from "@/lib/plugin-hooks";
|
||||
import { allowEmailMove, emitEmailMove, keywordChangeLists } from "@/lib/plugin-mail-hooks";
|
||||
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";
|
||||
@@ -1433,6 +1434,25 @@ async function refreshMailboxesForViewingAccount(fallbackClient: IJMAPClient): P
|
||||
// still carries a bare owner id (scoped to the owning account via
|
||||
// `sourceAccountId === mailbox.accountId` so a bare owner id can't collide with
|
||||
// another account's folder). (#281)
|
||||
/**
|
||||
* The folder messages are moved out of, for the move hooks: the open folder,
|
||||
* or in an aggregate view (unified, tag, search across folders) the one
|
||||
* folder they all share, else null.
|
||||
*/
|
||||
function moveSourceMailboxId(emails: Array<Pick<Email, 'mailboxIds' | 'sourceAccountId'>>, mailboxes: Mailbox[]): string | null {
|
||||
const { selectedMailbox, selectedKeyword, isUnifiedView } = useEmailStore.getState();
|
||||
if (!selectedKeyword && !isUnifiedView && selectedMailbox && mailboxes.some(mb => mb.id === selectedMailbox)) {
|
||||
return selectedMailbox;
|
||||
}
|
||||
const sources = new Set<string>();
|
||||
for (const email of emails) {
|
||||
const mailbox = mailboxes.find(mb => emailInMailbox(email, mb));
|
||||
if (!mailbox) return null;
|
||||
sources.add(mailbox.id);
|
||||
}
|
||||
return sources.size === 1 ? [...sources][0] : null;
|
||||
}
|
||||
|
||||
function emailInMailbox(
|
||||
email: { mailboxIds?: Record<string, boolean>; sourceAccountId?: string },
|
||||
mailbox: Mailbox,
|
||||
@@ -1549,6 +1569,11 @@ async function runBatchSpamAction(
|
||||
if (targets.length === 0) {
|
||||
throw new Error('Emails not found');
|
||||
}
|
||||
const spam = role === 'junk';
|
||||
if (emailHooks.onBeforeEmailSpamToggle.size > 0
|
||||
&& !(await emailHooks.onBeforeEmailSpamToggle.intercept({ emailIds: [...new Set(targets.map(e => e.id))], spam }))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const destinationByKey = new Map<string, string | undefined>();
|
||||
const failures: unknown[] = [];
|
||||
@@ -1598,6 +1623,7 @@ async function runBatchSpamAction(
|
||||
...patch,
|
||||
};
|
||||
});
|
||||
void emailHooks.onEmailSpamToggle.emit({ emailIds: [...new Set(moved.map(e => e.id))], spam });
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
@@ -2790,6 +2816,12 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
forceDelete = true;
|
||||
}
|
||||
|
||||
const permanent = !((deleteAction === 'trash' || deleteAction === 'trash-and-read') && !forceDelete);
|
||||
if (emailHooks.onBeforeEmailDelete.size > 0
|
||||
&& !(await emailHooks.onBeforeEmailDelete.intercept({ emailIds: [emailId], permanent }))) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If deleteAction is 'trash' or 'trash-and-read' and not forced permanent delete, try to move to trash mailbox
|
||||
if ((deleteAction === 'trash' || deleteAction === 'trash-and-read') && !forceDelete) {
|
||||
const trashMailbox = findTrashMailbox(mailboxes, { accountId });
|
||||
@@ -2837,6 +2869,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
...mailboxPatch,
|
||||
};
|
||||
});
|
||||
void emailHooks.onAfterEmailDelete.emit({ emailIds: [emailId], permanent: false });
|
||||
return;
|
||||
}
|
||||
// No trash folder found in this account. Surface the failure rather
|
||||
@@ -2870,6 +2903,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
...mailboxPatch,
|
||||
};
|
||||
});
|
||||
void emailHooks.onAfterEmailDelete.emit({ emailIds: [emailId], permanent: true });
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : "Failed to delete email"
|
||||
@@ -2902,6 +2936,16 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
processingReadStatus: new Set([...state.processingReadStatus, processingKey])
|
||||
}));
|
||||
|
||||
if (emailHooks.onBeforeEmailReadStateChange.size > 0
|
||||
&& !(await emailHooks.onBeforeEmailReadStateChange.intercept({ emailIds: [emailId], read }))) {
|
||||
set((state) => {
|
||||
const next = new Set(state.processingReadStatus);
|
||||
next.delete(processingKey);
|
||||
return { processingReadStatus: next };
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// In unified view route to the email's own account client; otherwise use
|
||||
// the active/viewing client and the shared-folder accountId. (#281)
|
||||
const { client: actionClient, accountId } = resolveEmailActionContext(email, client);
|
||||
@@ -2979,6 +3023,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
})(),
|
||||
};
|
||||
});
|
||||
void emailHooks.onEmailReadStateChange.emit({ emailIds: [emailId], read });
|
||||
refillAfterKeywordChange(get, client, ['$seen']);
|
||||
} catch (error) {
|
||||
// Remove from processing set on error
|
||||
@@ -3057,6 +3102,9 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
const destMailbox = mailboxes.find(mb => mb.id === destinationMailboxId);
|
||||
const jmapDestId = destMailbox?.originalId || destinationMailboxId;
|
||||
|
||||
const move = { emailIds: [emailId], fromMailboxId: moveSourceMailboxId([email], mailboxes), toMailboxId: destinationMailboxId };
|
||||
if (!(await allowEmailMove(move, mailboxes))) return;
|
||||
|
||||
await actionClient.moveEmail(emailId, jmapDestId, accountId);
|
||||
|
||||
set((state) => {
|
||||
@@ -3090,6 +3138,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
...mailboxPatch,
|
||||
};
|
||||
});
|
||||
emitEmailMove(move, mailboxes);
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : "Failed to move email"
|
||||
@@ -3112,6 +3161,10 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
const jmapDestId = destMailbox?.originalId || destinationMailboxId;
|
||||
const idSet = new Set(emailIds);
|
||||
const affected = emails.filter(e => idSet.has(e.id));
|
||||
const move = { emailIds: [...idSet], fromMailboxId: moveSourceMailboxId(affected, mailboxes), toMailboxId: destinationMailboxId };
|
||||
if (!(await allowEmailMove(move, mailboxes))) return;
|
||||
// Messages of an account no client reaches stay where they are.
|
||||
const skipped = new Set<string>();
|
||||
|
||||
if (isAggregateListView()) {
|
||||
// In unified view, emails may span accounts – group by owning JMAP account
|
||||
@@ -3128,7 +3181,10 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
const acctClient = sourceAccountId === '__default__'
|
||||
? resolveActionClient(client)
|
||||
: (clientAccountId ? useAuthStore.getState().getClientForAccount(clientAccountId) : undefined);
|
||||
if (!acctClient) return;
|
||||
if (!acctClient) {
|
||||
ids.forEach(id => skipped.add(id));
|
||||
return;
|
||||
}
|
||||
const jmapAccountId = sourceAccountId === '__default__' ? undefined : sourceAccountId;
|
||||
await acctClient.batchMoveEmails(ids, jmapDestId, jmapAccountId);
|
||||
}));
|
||||
@@ -3178,6 +3234,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
...patch,
|
||||
};
|
||||
});
|
||||
emitEmailMove({ ...move, emailIds: move.emailIds.filter(id => !skipped.has(id)) }, mailboxes);
|
||||
} catch (error) {
|
||||
set({ error: error instanceof Error ? error.message : 'Failed to move emails' });
|
||||
throw error;
|
||||
@@ -3187,6 +3244,15 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
crossAccountMoveEmails: async (emailIdsBySource, destAccountId, destMailboxId, destJmapAccountId, sourceJmapAccountId, options) => {
|
||||
if (emailIdsBySource.size === 0) return;
|
||||
const keepOriginal = options?.keepOriginal === true;
|
||||
// A copy leaves the messages where they are, so only a move asks plugins.
|
||||
const hookMailboxes = [...resolveActionMailboxes(), ...(get().accountMailboxes[destAccountId] ?? [])];
|
||||
const requestedIds = [...new Set([...emailIdsBySource.values()].flat())];
|
||||
const move = keepOriginal ? null : {
|
||||
emailIds: requestedIds,
|
||||
fromMailboxId: moveSourceMailboxId(get().emails.filter(e => requestedIds.includes(e.id)), hookMailboxes),
|
||||
toMailboxId: destMailboxId,
|
||||
};
|
||||
if (move && !(await allowEmailMove(move, hookMailboxes))) return;
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const destClient = useAuthStore.getState().getClientForAccount(destAccountId);
|
||||
@@ -3290,6 +3356,8 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
}
|
||||
}
|
||||
|
||||
if (move && movedIds.length > 0) emitEmailMove({ ...move, emailIds: movedIds }, hookMailboxes);
|
||||
|
||||
if (failures.length > 0) {
|
||||
const first = failures[0];
|
||||
const verb = keepOriginal ? 'copy' : 'move';
|
||||
@@ -3383,6 +3451,9 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
return;
|
||||
}
|
||||
|
||||
const move = { emailIds: threadEmailIds, fromMailboxId: moveSourceMailboxId([email], mailboxes), toMailboxId: destinationMailboxId };
|
||||
if (!(await allowEmailMove(move, mailboxes))) return;
|
||||
|
||||
await effectiveClient.batchMoveEmails(threadEmailIds, jmapDestId, accountId);
|
||||
|
||||
const removedEmailIds = new Set(threadEmailIds);
|
||||
@@ -3407,6 +3478,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
threadEmailsCache: nextThreadEmailsCache,
|
||||
};
|
||||
});
|
||||
emitEmailMove(move, mailboxes);
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : "Failed to move email thread"
|
||||
@@ -3692,6 +3764,10 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
if (!email) return;
|
||||
|
||||
const isFlagged = email.keywords.$flagged || false;
|
||||
if (emailHooks.onBeforeEmailStarToggle.size > 0
|
||||
&& !(await emailHooks.onBeforeEmailStarToggle.intercept({ emailIds: [emailId], starred: !isFlagged }))) {
|
||||
return;
|
||||
}
|
||||
// In unified view route to the email's own account client + owner accountId
|
||||
// (the reaching client's primary is not the owner for shared sources). (#281)
|
||||
const { client: actionClient, accountId } = resolveEmailActionContext(email, client);
|
||||
@@ -3714,6 +3790,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
emailId,
|
||||
),
|
||||
}));
|
||||
void emailHooks.onEmailStarToggle.emit({ emailIds: [emailId], starred: !isFlagged });
|
||||
refillAfterKeywordChange(get, client, ['$flagged']);
|
||||
} catch (error) {
|
||||
set({
|
||||
@@ -3728,8 +3805,13 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
// $mdnsent). Same routing, same reason: an unrouted write against the
|
||||
// reaching client's primary account is a silent no-op on a shared/group
|
||||
// message. Best-effort by contract - callers treat a failure as non-fatal.
|
||||
if (emailHooks.onBeforeEmailKeywordChange.size > 0
|
||||
&& !(await emailHooks.onBeforeEmailKeywordChange.intercept({ emailIds: [emailId], added: [keyword], removed: [] }))) {
|
||||
return;
|
||||
}
|
||||
const { client: actionClient, accountId } = resolveKeywordActionContext(emailId, client);
|
||||
await actionClient.setKeyword(emailId, keyword, accountId);
|
||||
void emailHooks.onEmailKeywordChange.emit({ emailIds: [emailId], added: [keyword], removed: [] });
|
||||
},
|
||||
|
||||
setEmailKeywords: async (client, emailId, keywords) => {
|
||||
@@ -3760,6 +3842,10 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
patchEmailKeywords: async (client, emailId, changes) => {
|
||||
const keys = Object.keys(changes);
|
||||
if (keys.length === 0) return;
|
||||
if (emailHooks.onBeforeEmailKeywordChange.size > 0
|
||||
&& !(await emailHooks.onBeforeEmailKeywordChange.intercept({ emailIds: [emailId], ...keywordChangeLists(changes) }))) {
|
||||
return;
|
||||
}
|
||||
const { client: actionClient, accountId } = resolveKeywordActionContext(emailId, client);
|
||||
// `keywords/<name>` pointers touch only these keywords on the server;
|
||||
// a cleared keyword is removed (null), since a keyword map holds `true`
|
||||
@@ -3774,6 +3860,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
else delete next[key];
|
||||
}
|
||||
get().setEmailKeywordsLocal(emailId, next);
|
||||
void emailHooks.onEmailKeywordChange.emit({ emailIds: [emailId], ...keywordChangeLists(changes) });
|
||||
refillAfterKeywordChange(get, client, keys.filter(k => !!previous[k] !== !!changes[k]));
|
||||
},
|
||||
|
||||
@@ -3809,9 +3896,15 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
const targetIds = target ? new Set(target.emailIds) : null;
|
||||
const emailIdsArray = targetIds ? Array.from(targetIds) : selectedEmails.map(e => e.id);
|
||||
if (emailIdsArray.length === 0) return;
|
||||
if (emailHooks.onBeforeEmailReadStateChange.size > 0
|
||||
&& !(await emailHooks.onBeforeEmailReadStateChange.intercept({ emailIds: [...new Set(emailIdsArray)], read }))) {
|
||||
return;
|
||||
}
|
||||
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
// Ids of an account whose write failed or that no client reaches.
|
||||
const notWritten = new Set<string>();
|
||||
|
||||
if (target) {
|
||||
// Messages named by the caller need not be loaded, so the list's
|
||||
@@ -3830,9 +3923,15 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
const acctClient = sourceAccountId === '__default__'
|
||||
? resolveActionClient(client)
|
||||
: (clientAccountId ? useAuthStore.getState().getClientForAccount(clientAccountId) : undefined);
|
||||
if (!acctClient) return;
|
||||
if (!acctClient) {
|
||||
ids.forEach(id => notWritten.add(id));
|
||||
return;
|
||||
}
|
||||
const jmapAccountId = sourceAccountId === '__default__' ? undefined : sourceAccountId;
|
||||
await acctClient.batchMarkAsRead(ids, read, jmapAccountId);
|
||||
await acctClient.batchMarkAsRead(ids, read, jmapAccountId).catch((error) => {
|
||||
ids.forEach(id => notWritten.add(id));
|
||||
throw error;
|
||||
});
|
||||
});
|
||||
await Promise.allSettled(promises);
|
||||
} else {
|
||||
@@ -3892,6 +3991,8 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
selectedEmailKeys: target ? get().selectedEmailKeys : new Set(),
|
||||
isLoading: false
|
||||
});
|
||||
const changedIds = [...new Set(emailIdsArray)].filter(id => !notWritten.has(id));
|
||||
if (changedIds.length > 0) void emailHooks.onEmailReadStateChange.emit({ emailIds: changedIds, read });
|
||||
refillAfterKeywordChange(get, client, ['$seen']);
|
||||
} catch (error) {
|
||||
set({
|
||||
@@ -3925,14 +4026,24 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
byAccount.set(accountId, [...(byAccount.get(accountId) ?? []), email]);
|
||||
}
|
||||
|
||||
if (groups.size === 0) return;
|
||||
const tagChange = add ? { added: changedKeys, removed: [] } : { added: [], removed: changedKeys };
|
||||
if (emailHooks.onBeforeEmailKeywordChange.size > 0) {
|
||||
const emailIds = [...new Set([...groups.values()].flatMap(byAccount => [...byAccount.values()].flat().map(e => e.id)))];
|
||||
if (!(await emailHooks.onBeforeEmailKeywordChange.intercept({ emailIds, ...tagChange }))) return;
|
||||
}
|
||||
|
||||
const writes = [...groups].flatMap(([actionClient, byAccount]) =>
|
||||
[...byAccount].map(async ([accountId, group]) => {
|
||||
await actionClient.batchUpdateKeywords(group.map(e => e.id), patch, accountId);
|
||||
return group.map(emailKeyFor);
|
||||
return group;
|
||||
}),
|
||||
);
|
||||
if (writes.length === 0) return;
|
||||
const results = await Promise.allSettled(writes);
|
||||
const settled = await Promise.allSettled(writes);
|
||||
const writtenEmails = settled.flatMap(result => result.status === 'fulfilled' ? result.value : []);
|
||||
const results = settled.map(result => result.status === 'fulfilled'
|
||||
? { status: 'fulfilled' as const, value: result.value.map(emailKeyFor) }
|
||||
: result);
|
||||
|
||||
// Only the accounts whose write went through are patched locally.
|
||||
const written = new Set(results.flatMap(result => result.status === 'fulfilled' ? result.value : []));
|
||||
@@ -3949,6 +4060,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
: state.selectedEmail,
|
||||
}));
|
||||
if (written.size > 0) {
|
||||
void emailHooks.onEmailKeywordChange.emit({ emailIds: [...new Set(writtenEmails.map(e => e.id))], ...tagChange });
|
||||
void get().fetchTagCounts(client);
|
||||
refillAfterKeywordChange(get, client, changedKeys);
|
||||
}
|
||||
@@ -3962,6 +4074,15 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
const mailboxes = resolveActionMailboxes();
|
||||
if (selectedEmailKeys.size === 0) return;
|
||||
|
||||
if (emailHooks.onBeforeEmailDelete.size > 0) {
|
||||
const toDelete = resolveSelectedEmails(get());
|
||||
if (toDelete.length > 0) {
|
||||
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
|
||||
const destroys = permanent || bulkDeleteIsPermanent(currentMailbox);
|
||||
if (!(await emailHooks.onBeforeEmailDelete.intercept({ emailIds: [...new Set(toDelete.map(e => e.id))], permanent: destroys }))) return;
|
||||
}
|
||||
}
|
||||
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
// The selection is keyed by owning account; JMAP takes the bare ids of
|
||||
@@ -4012,11 +4133,14 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
const jmapIdFor = (sourceAccountId: string) =>
|
||||
sourceAccountId === '__default__' ? viewAccountId : sourceAccountId;
|
||||
|
||||
// Ids the server confirmed, for onAfterEmailDelete.
|
||||
const deletedIds = new Set<string>();
|
||||
if (forceDestroy) {
|
||||
const promises = Array.from(bySource.entries()).map(async ([sourceAccountId, { clientAccountId, ids }]) => {
|
||||
const acctClient = getClient(sourceAccountId, clientAccountId);
|
||||
if (!acctClient) return;
|
||||
await acctClient.batchDeleteEmails(ids, jmapIdFor(sourceAccountId));
|
||||
ids.forEach(id => deletedIds.add(id));
|
||||
});
|
||||
await Promise.allSettled(promises);
|
||||
} else {
|
||||
@@ -4042,6 +4166,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
const trashId = trashMailbox.originalId || trashMailbox.id;
|
||||
await acctClient.batchMoveEmails(ids, trashId, jmapIdFor(sourceAccountId), alsoMarkRead);
|
||||
keys.forEach(key => movedKeys.add(key));
|
||||
ids.forEach(id => deletedIds.add(id));
|
||||
});
|
||||
await Promise.allSettled(promises);
|
||||
|
||||
@@ -4063,6 +4188,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
isLoading: false,
|
||||
error: 'Some emails could not be moved: trash folder missing for one or more accounts',
|
||||
});
|
||||
void emailHooks.onAfterEmailDelete.emit({ emailIds: [...deletedIds], permanent: false });
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -4081,6 +4207,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
selectedEmail: null,
|
||||
isLoading: false
|
||||
});
|
||||
if (deletedIds.size > 0) void emailHooks.onAfterEmailDelete.emit({ emailIds: [...deletedIds], permanent: forceDestroy });
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : "Failed to delete emails",
|
||||
@@ -4096,9 +4223,18 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
const targetIds = target ? new Set(target.emailIds) : null;
|
||||
const emailIdsArray = targetIds ? Array.from(targetIds) : selectedEmails.map(e => e.id);
|
||||
if (emailIdsArray.length === 0) return;
|
||||
const hookMailboxes = resolveActionMailboxes();
|
||||
const move = {
|
||||
emailIds: [...new Set(emailIdsArray)],
|
||||
fromMailboxId: moveSourceMailboxId(targetIds ? emails.filter(e => targetIds.has(e.id)) : selectedEmails, hookMailboxes),
|
||||
toMailboxId,
|
||||
};
|
||||
if (!(await allowEmailMove(move, hookMailboxes))) return;
|
||||
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
// Ids of an account whose move failed or that no client reaches.
|
||||
const notMoved = new Set<string>();
|
||||
|
||||
if (target) {
|
||||
// Messages named by the caller need not be loaded, so the list's
|
||||
@@ -4120,9 +4256,15 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
const acctClient = sourceAccountId === '__default__'
|
||||
? resolveActionClient(client)
|
||||
: (clientAccountId ? useAuthStore.getState().getClientForAccount(clientAccountId) : undefined);
|
||||
if (!acctClient) return;
|
||||
if (!acctClient) {
|
||||
ids.forEach(id => notMoved.add(id));
|
||||
return;
|
||||
}
|
||||
const jmapAccountId = sourceAccountId === '__default__' ? undefined : sourceAccountId;
|
||||
await acctClient.batchMoveEmails(ids, jmapDestId, jmapAccountId);
|
||||
await acctClient.batchMoveEmails(ids, jmapDestId, jmapAccountId).catch((error) => {
|
||||
ids.forEach(id => notMoved.add(id));
|
||||
throw error;
|
||||
});
|
||||
});
|
||||
await Promise.allSettled(promises);
|
||||
} else {
|
||||
@@ -4145,6 +4287,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
selectedEmailKeys: target ? dropFromSelection(get(), moved) : new Set(),
|
||||
isLoading: false
|
||||
});
|
||||
emitEmailMove({ ...move, emailIds: move.emailIds.filter(id => !notMoved.has(id)) }, hookMailboxes);
|
||||
|
||||
// Refresh emails to get updated list (honors active search/filters)
|
||||
if (!get().isUnifiedView) {
|
||||
@@ -4188,6 +4331,14 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
const selected = emails.filter(e => selectedEmailKeys.has(emailKeyFor(e)));
|
||||
if (selected.length === 0) return;
|
||||
|
||||
const hookMailboxes = [...resolveActionMailboxes(), archiveMailbox];
|
||||
const move = {
|
||||
emailIds: [...new Set(selected.map(e => e.id))],
|
||||
fromMailboxId: moveSourceMailboxId(selected, hookMailboxes),
|
||||
toMailboxId: archiveMailbox.id,
|
||||
};
|
||||
if (!(await allowEmailMove(move, hookMailboxes))) return;
|
||||
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
await resolveActionClient(client).batchArchiveEmails(
|
||||
@@ -4200,6 +4351,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
|
||||
const remaining = emails.filter(e => !selectedEmailKeys.has(emailKeyFor(e)));
|
||||
set({ emails: remaining, selectedEmailKeys: new Set(), isLoading: false });
|
||||
emitEmailMove(move, hookMailboxes);
|
||||
|
||||
// Refresh the active or viewed account's mailbox cache after the
|
||||
// archive (a year/month archive can create new sub-folders).
|
||||
@@ -4226,6 +4378,10 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
if (!email) {
|
||||
throw new Error('Email not found - cannot mark as spam');
|
||||
}
|
||||
if (emailHooks.onBeforeEmailSpamToggle.size > 0
|
||||
&& !(await emailHooks.onBeforeEmailSpamToggle.intercept({ emailIds: [emailId], spam: true }))) {
|
||||
return;
|
||||
}
|
||||
|
||||
// In unified view route to the email's own account (client + that account's
|
||||
// mailbox list); otherwise the active/viewing context. (#281)
|
||||
@@ -4294,6 +4450,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
...mailboxPatch,
|
||||
};
|
||||
});
|
||||
void emailHooks.onEmailSpamToggle.emit({ emailIds: [emailId], spam: true });
|
||||
} catch (error) {
|
||||
console.error('Failed to mark as spam:', error);
|
||||
throw error;
|
||||
@@ -4301,6 +4458,10 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
},
|
||||
|
||||
undoSpam: async (client, emailId) => {
|
||||
if (emailHooks.onBeforeEmailSpamToggle.size > 0
|
||||
&& !(await emailHooks.onBeforeEmailSpamToggle.intercept({ emailIds: [emailId], spam: false }))) {
|
||||
return;
|
||||
}
|
||||
const { selectedMailbox } = get();
|
||||
const mailboxes = resolveActionMailboxes();
|
||||
|
||||
@@ -4360,6 +4521,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
emails: state.emails.filter(e => e.id !== emailId),
|
||||
selectedEmail: getNextSelectedEmail(state, emailId),
|
||||
}));
|
||||
void emailHooks.onEmailSpamToggle.emit({ emailIds: [emailId], spam: false });
|
||||
|
||||
// Refresh the view the user is actually looking at. If they leave it
|
||||
// while the account list loads, skip the fetch rather than re-enter it.
|
||||
@@ -5026,6 +5188,10 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
.map(e => e.id);
|
||||
|
||||
if (unreadIds.length === 0) return;
|
||||
if (emailHooks.onBeforeEmailReadStateChange.size > 0
|
||||
&& !(await emailHooks.onBeforeEmailReadStateChange.intercept({ emailIds: [...unreadIds], read: true }))) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Group by account for unified view support
|
||||
const emailsById = new Map<string, Email>();
|
||||
@@ -5092,6 +5258,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
: state.selectedEmail,
|
||||
};
|
||||
});
|
||||
void emailHooks.onEmailReadStateChange.emit({ emailIds: [...unreadIds], read: true });
|
||||
refillAfterKeywordChange(get, client, ['$seen']);
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user