feat: tell plugins when messages are deleted, moved, archived, marked or tagged, and let them stop it

This commit is contained in:
Linus Rath
2026-10-14 03:54:40 +02:00
parent af53675191
commit 286cce6877
5 changed files with 703 additions and 14 deletions
+6
View File
@@ -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) {
+15 -6
View File
@@ -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);
+100
View File
@@ -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
View File
@@ -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']);
},