feat: add onEmailsFetched and onSearchResults hook + new JMAP method getSomeEmails

This commit is contained in:
Paulhenry Saux
2026-07-13 18:18:57 +02:00
committed by Linus Rath
parent c1acf58c5f
commit 622adc34de
5 changed files with 154 additions and 81 deletions
+13
View File
@@ -166,6 +166,19 @@ export class DemoJMAPClient implements IJMAPClient {
return { emails, hasMore: position + limit < total, total }; return { emails, hasMore: position + limit < total, total };
} }
async getSomeEmails(emailsId: string[], _accountId?: string): Promise<Email[]> {
if (!emailsId || emailsId.length === 0) {
return [];
}
const filtered = this.data.emails.filter(e => emailsId.includes(e.id));
filtered.sort((a, b) =>
new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime()
);
return filtered;
}
async getEmailsInMailbox(mailboxId: string): Promise<Email[]> { async getEmailsInMailbox(mailboxId: string): Promise<Email[]> {
return this.data.emails.filter(e => e.mailboxIds[mailboxId]); return this.data.emails.filter(e => e.mailboxIds[mailboxId]);
} }
+1
View File
@@ -83,6 +83,7 @@ export interface IJMAPClient {
getEmails(mailboxId?: string, accountId?: string, limit?: number, position?: number, hasKeyword?: string, pinnedFirst?: boolean): Promise<{ emails: Email[]; hasMore: boolean; total: number }>; getEmails(mailboxId?: string, accountId?: string, limit?: number, position?: number, hasKeyword?: string, pinnedFirst?: boolean): Promise<{ emails: Email[]; hasMore: boolean; total: number }>;
getEmailsInMailbox(mailboxId: string): Promise<Email[]>; getEmailsInMailbox(mailboxId: string): Promise<Email[]>;
getEmail(emailId: string, accountId?: string): Promise<Email | null>; getEmail(emailId: string, accountId?: string): Promise<Email | null>;
getSomeEmails(emailsId: string[], accountId?: string): Promise<Email[]>
getTagCounts(tagIds: string[]): Promise<Record<string, { total: number; unread: number }>>; getTagCounts(tagIds: string[]): Promise<Record<string, { total: number; unread: number }>>;
searchEmails(query: string, mailboxId?: string, accountId?: string, limit?: number, position?: number): Promise<{ emails: Email[]; hasMore: boolean; total: number }>; searchEmails(query: string, mailboxId?: string, accountId?: string, limit?: number, position?: number): Promise<{ emails: Email[]; hasMore: boolean; total: number }>;
advancedSearchEmails( advancedSearchEmails(
+37
View File
@@ -550,6 +550,43 @@ export class JMAPClient implements IJMAPClient {
this.authHeader = `Bearer ${token}`; this.authHeader = `Bearer ${token}`;
} }
async getSomeEmails(emailsId: string[], accountId?: string): Promise<Email[]> {
try {
const targetAccountId = accountId || this.accountId;
if (!emailsId || emailsId.length === 0) {
return [];
}
const response = await this.request([
["Email/get", {
accountId: targetAccountId,
ids: emailsId,
properties: [...EMAIL_LIST_PROPERTIES],
}, "0"],
]);
const getResponse = response.methodResponses?.[0]?.[1];
if (response.methodResponses?.[0]?.[0] === "Email/get" && getResponse) {
const emails = (getResponse.list || []) as Email[];
emails.sort((a: Email, b: Email) =>
new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime()
);
if (accountId && accountId !== this.accountId) {
namespaceMailboxIds(emails, accountId);
}
return emails;
}
return [];
} catch (error) {
console.error('Failed to get specific emails:', error);
return [];
}
}
/** Upgrade an existing basic-auth client to bearer-token auth (e.g. after TOTP token exchange). */ /** Upgrade an existing basic-auth client to bearer-token auth (e.g. after TOTP token exchange). */
upgradeToBearer(accessToken: string, onRefresh?: () => Promise<string | null>): void { upgradeToBearer(accessToken: string, onRefresh?: () => Promise<string | null>): void {
this.authMode = 'bearer'; this.authMode = 'bearer';
+4
View File
@@ -272,6 +272,10 @@ export const emailHooks = {
// normally. This is the send-takeover hook used by the S/MIME plugin to // normally. This is the send-takeover hook used by the S/MIME plugin to
// replace the former native sign+encrypt+sendRaw pipeline. // replace the former native sign+encrypt+sendRaw pipeline.
onComposeSend: new HookBus(), onComposeSend: new HookBus(),
// Transform hook - receive Email[] or ScheduledEmail[] just after there are fetched to
// lets plugin edit emails before they are shown in row. Used to populate preview
// field for encryption plugins.
onEmailsFetched: new HookBus(),
}; };
// §7.2 Calendar Hooks // §7.2 Calendar Hooks
+98 -80
View File
@@ -918,8 +918,9 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
for (const email of result.emails) { for (const email of result.emails) {
email.sourceFolder = resolveSourceFolderName(email, allMailMailboxes); email.sourceFolder = resolveSourceFolderName(email, allMailMailboxes);
} }
const enrichedEmails = await emailHooks.onEmailsFetched.transform(result.emails);
set({ set({
emails: annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId), emails: annotateScheduledEmails(enrichedEmails, get().scheduledSubmissionByEmailId),
hasMoreEmails: result.hasMore, hasMoreEmails: result.hasMore,
totalEmails: result.total, totalEmails: result.total,
isLoading: false, isLoading: false,
@@ -946,8 +947,9 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// When filtering by tag, omit the mailbox constraint so emails across // When filtering by tag, omit the mailbox constraint so emails across
// all folders that carry the tag are returned. // all folders that carry the tag are returned.
const result = await effectiveClient.getEmails(selectedKeyword ? undefined : jmapMailboxId, accountId, emailsPerPage, 0, keywordFilter, true); const result = await effectiveClient.getEmails(selectedKeyword ? undefined : jmapMailboxId, accountId, emailsPerPage, 0, keywordFilter, true);
const enrichedEmails = await emailHooks.onEmailsFetched.transform(result.emails);
set({ set({
emails: annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId), emails: annotateScheduledEmails(enrichedEmails, get().scheduledSubmissionByEmailId),
hasMoreEmails: result.hasMore, hasMoreEmails: result.hasMore,
totalEmails: result.total, totalEmails: result.total,
// Clear thread caches since the email list was fully replaced // Clear thread caches since the email list was fully replaced
@@ -991,8 +993,9 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
const currentEmails = get().emails; const currentEmails = get().emails;
const existingIds = new Set(currentEmails.map(e => e.id)); const existingIds = new Set(currentEmails.map(e => e.id));
const newEmails = result.emails.filter(e => !existingIds.has(e.id)); const newEmails = result.emails.filter(e => !existingIds.has(e.id));
const enrichedNewEmails = await emailHooks.onEmailsFetched.transform(newEmails);
set({ set({
emails: [...currentEmails, ...newEmails], emails: [...currentEmails, ...enrichedNewEmails],
hasMoreEmails: result.hasMore, hasMoreEmails: result.hasMore,
totalEmails: result.total, totalEmails: result.total,
isLoadingMore: false, isLoadingMore: false,
@@ -1034,8 +1037,9 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
const currentEmails = get().emails; const currentEmails = get().emails;
const existingIds = new Set(currentEmails.map(e => e.id)); const existingIds = new Set(currentEmails.map(e => e.id));
const newEmails = result.emails.filter(e => !existingIds.has(e.id)); const newEmails = result.emails.filter(e => !existingIds.has(e.id));
const enrichedNewEmails = await emailHooks.onEmailsFetched.transform(newEmails);
set({ set({
emails: [...currentEmails, ...newEmails], emails: [...currentEmails, ...enrichedNewEmails],
hasMoreEmails: result.hasMore, hasMoreEmails: result.hasMore,
totalEmails: result.total, totalEmails: result.total,
isLoadingMore: false, isLoadingMore: false,
@@ -1127,14 +1131,15 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
const existingIds = new Set(currentEmails.map(e => e.id)); const existingIds = new Set(currentEmails.map(e => e.id));
const newEmails = annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId).filter((e: Email) => !existingIds.has(e.id)); const newEmails = annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId).filter((e: Email) => !existingIds.has(e.id));
const enrichedNewEmails = await emailHooks.onEmailsFetched.transform(newEmails);
set({ set({
emails: [...currentEmails, ...newEmails], emails: [...currentEmails, ...enrichedNewEmails],
hasMoreEmails: result.hasMore, hasMoreEmails: result.hasMore,
totalEmails: result.total, totalEmails: result.total,
isLoadingMore: false isLoadingMore: false
}); });
// Fetch full thread counts for newly loaded threads in the background // Fetch full thread counts for newly loaded threads in the background
if (newEmails.length > 0) { if (enrichedNewEmails.length > 0) {
void get().fetchThreadEmailCounts(client); void get().fetchThreadEmailCounts(client);
} }
} catch (error) { } catch (error) {
@@ -1754,60 +1759,66 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
searchEmails: async (client, query) => { searchEmails: async (client, query) => {
set({ isLoading: true, error: null, searchQuery: query, emails: [], hasMoreEmails: false, totalEmails: 0 }); // Clear emails for loading state set({ isLoading: true, error: null, searchQuery: query, emails: [], hasMoreEmails: false, totalEmails: 0 }); // Clear emails for loading state
try { try {
const { isUnifiedView, unifiedRole, crossView } = get(); const { isUnifiedView, unifiedRole, crossView, selectedMailbox, searchFilters } = get();
const emailsPerPage = useSettingsStore.getState().emailsPerPage; const emailsPerPage = useSettingsStore.getState().emailsPerPage;
let result;
let accountId;
let unifiedErrors;
if (isUnifiedView && crossView) { if (isUnifiedView && crossView) {
const includeGroup = useSettingsStore.getState().includeGroupInUnified; const includeGroup = useSettingsStore.getState().includeGroupInUnified;
const built = await buildUnifiedAccountClients({ includeGroup }); const built = await buildUnifiedAccountClients({ includeGroup });
const result = await searchCrossViewEmails(built, crossView, query, emailsPerPage, 0); result = await searchCrossViewEmails(built, crossView, query, emailsPerPage, 0);
const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query, filters: get().searchFilters }); unifiedErrors = result.errors;
set({
emails: result.emails,
externalSearchResults: externals,
hasMoreEmails: result.hasMore,
totalEmails: result.total,
isLoading: false,
unifiedErrors: result.errors,
});
return;
}
if (isUnifiedView && unifiedRole) { } else if (isUnifiedView && unifiedRole) {
const includeGroup = useSettingsStore.getState().includeGroupInUnified; const includeGroup = useSettingsStore.getState().includeGroupInUnified;
const built = await buildUnifiedAccountClients({ includeGroup }); const built = await buildUnifiedAccountClients({ includeGroup });
const result = await searchUnifiedEmails(built, unifiedRole, query, emailsPerPage, 0); result = await searchUnifiedEmails(built, unifiedRole, query, emailsPerPage, 0);
const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query, filters: get().searchFilters }); unifiedErrors = result.errors;
set({
emails: result.emails, } else {
externalSearchResults: externals, // Get the current mailbox to scope the search. In the All Mail view the
hasMoreEmails: result.hasMore, // search spans every folder of the account (no inMailbox constraint).
totalEmails: result.total, const isAllMail = selectedMailbox === ALL_MAIL_MAILBOX_ID;
isLoading: false, const mailboxes = resolveActionMailboxes();
unifiedErrors: result.errors, const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
}); // Use originalId for shared mailboxes
return; const jmapMailboxId = isAllMail ? undefined : (mailbox?.originalId || selectedMailbox);
// Only pass accountId for shared mailboxes, not for primary account
accountId = isAllMail ? undefined : (mailbox?.isShared ? mailbox.accountId : undefined);
result = await resolveActionClient(client).searchEmails(query, jmapMailboxId, accountId, emailsPerPage, 0);
} }
// Get the current mailbox to scope the search. In the All Mail view the const hookEdit = await emailHooks.onSearchResults.transform({
// search spans every folder of the account (no inMailbox constraint). newEmailIds: [] as string[],
const selectedMailbox = get().selectedMailbox; result: result,
const isAllMail = selectedMailbox === ALL_MAIL_MAILBOX_ID; query: query,
const mailboxes = resolveActionMailboxes(); filters: searchFilters
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox); });
// Use originalId for shared mailboxes
const jmapMailboxId = isAllMail ? undefined : (mailbox?.originalId || selectedMailbox);
// Only pass accountId for shared mailboxes, not for primary account
const accountId = isAllMail ? undefined : (mailbox?.isShared ? mailbox.accountId : undefined);
const result = await resolveActionClient(client).searchEmails(query, jmapMailboxId, accountId, emailsPerPage, 0); result = hookEdit.result;
const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query, filters: get().searchFilters }); if (hookEdit.newEmailIds.length > 0) {
// in unified, accountId will be undefined and we will use the default.
const newEmails = await resolveActionClient(client).getSomeEmails(hookEdit.newEmailIds, accountId);
result.emails.push(...newEmails);
result.total += newEmails.length;
}
const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], {
query,
filters: searchFilters
});
result.emails = await emailHooks.onEmailsFetched.transform(result.emails);
set({ set({
emails: annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId), emails: annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId),
externalSearchResults: externals, externalSearchResults: externals,
hasMoreEmails: result.hasMore, hasMoreEmails: result.hasMore,
totalEmails: result.total, totalEmails: result.total,
isLoading: false isLoading: false,
...(unifiedErrors ? { unifiedErrors } : {})
}); });
} catch (error) { } catch (error) {
set({ set({
@@ -1841,60 +1852,64 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
try { try {
const emailsPerPage = useSettingsStore.getState().emailsPerPage; const emailsPerPage = useSettingsStore.getState().emailsPerPage;
let result;
let accountId;
let unifiedErrors;
if (isUnifiedView && crossView) { if (isUnifiedView && crossView) {
const includeGroup = useSettingsStore.getState().includeGroupInUnified; const includeGroup = useSettingsStore.getState().includeGroupInUnified;
const built = await buildUnifiedAccountClients({ includeGroup }); const built = await buildUnifiedAccountClients({ includeGroup });
const result = await searchCrossViewEmails(built, crossView, searchQuery, emailsPerPage, 0); result = await searchCrossViewEmails(built, crossView, searchQuery, emailsPerPage, 0);
if (controller.signal.aborted) return; unifiedErrors = result.errors;
const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query: searchQuery, filters: searchFilters });
set({
emails: result.emails,
externalSearchResults: externals,
hasMoreEmails: result.hasMore,
totalEmails: result.total,
isLoading: false,
unifiedErrors: result.errors,
});
return;
}
if (isUnifiedView && unifiedRole) { } else if (isUnifiedView && unifiedRole) {
const includeGroup = useSettingsStore.getState().includeGroupInUnified; const includeGroup = useSettingsStore.getState().includeGroupInUnified;
const built = await buildUnifiedAccountClients({ includeGroup }); const built = await buildUnifiedAccountClients({ includeGroup });
const result = await advancedSearchUnifiedEmails( result = await advancedSearchUnifiedEmails(
built, built,
unifiedRole, unifiedRole,
(mailboxId) => buildJMAPFilter(searchQuery, searchFilters, mailboxId), (mailboxId) => buildJMAPFilter(searchQuery, searchFilters, mailboxId),
emailsPerPage, emailsPerPage,
0, 0,
); );
if (controller.signal.aborted) return; unifiedErrors = result.errors;
const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query: searchQuery, filters: searchFilters });
set({ } else {
emails: result.emails, const isAllMail = selectedMailbox === ALL_MAIL_MAILBOX_ID;
externalSearchResults: externals, const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
hasMoreEmails: result.hasMore, const jmapMailboxId = isAllMail ? undefined : (mailbox?.originalId || selectedMailbox);
totalEmails: result.total, accountId = isAllMail ? undefined : (mailbox?.isShared ? mailbox.accountId : undefined);
isLoading: false,
searchAbortController: null, const filter = buildJMAPFilter(searchQuery, searchFilters, jmapMailboxId);
unifiedErrors: result.errors, result = await resolveActionClient(client).advancedSearchEmails(filter, accountId, emailsPerPage, 0);
});
return;
} }
const isAllMail = selectedMailbox === ALL_MAIL_MAILBOX_ID;
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
const jmapMailboxId = isAllMail ? undefined : (mailbox?.originalId || selectedMailbox);
const accountId = isAllMail ? undefined : (mailbox?.isShared ? mailbox.accountId : undefined);
const filter = buildJMAPFilter(searchQuery, searchFilters, jmapMailboxId);
const result = await resolveActionClient(client).advancedSearchEmails(filter, accountId, emailsPerPage, 0);
if (controller.signal.aborted) return; if (controller.signal.aborted) return;
const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query: searchQuery, filters: searchFilters }); const hookEdit = await emailHooks.onSearchResults.transform({
newEmailIds: [] as string[],
result: result,
query: searchQuery,
filters: searchFilters
});
result = hookEdit.result;
if (hookEdit.newEmailIds.length > 0) {
const newEmails = await resolveActionClient(client).getSomeEmails(hookEdit.newEmailIds, accountId);
result.emails.push(...newEmails);
result.total += newEmails.length;
}
if (controller.signal.aborted) return;
const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], {
query: searchQuery,
filters: searchFilters
});
if (controller.signal.aborted) return;
result.emails = await emailHooks.onEmailsFetched.transform(result.emails);
set({ set({
emails: annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId), emails: annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId),
externalSearchResults: externals, externalSearchResults: externals,
@@ -1902,6 +1917,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
totalEmails: result.total, totalEmails: result.total,
isLoading: false, isLoading: false,
searchAbortController: null, searchAbortController: null,
...(unifiedErrors ? { unifiedErrors } : {})
}); });
} catch (error) { } catch (error) {
if (controller.signal.aborted) return; if (controller.signal.aborted) return;
@@ -3284,6 +3300,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
try { try {
const emailsPerPage = useSettingsStore.getState().emailsPerPage; const emailsPerPage = useSettingsStore.getState().emailsPerPage;
const result = await client.getScheduledEmails(emailsPerPage, 0); const result = await client.getScheduledEmails(emailsPerPage, 0);
result.emails = await emailHooks.onEmailsFetched.transform(result.emails);
const scheduledEmailIds = new Set(result.emails.map(email => email.id)); const scheduledEmailIds = new Set(result.emails.map(email => email.id));
const scheduledSubmissionByEmailId = new Map(result.emails.map(email => [email.id, { const scheduledSubmissionByEmailId = new Map(result.emails.map(email => [email.id, {
submissionId: email.emailSubmissionId, submissionId: email.emailSubmissionId,
@@ -3324,6 +3341,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
try { try {
const emailsPerPage = useSettingsStore.getState().emailsPerPage; const emailsPerPage = useSettingsStore.getState().emailsPerPage;
const result = await client.getScheduledEmails(emailsPerPage, scheduledNextPosition); const result = await client.getScheduledEmails(emailsPerPage, scheduledNextPosition);
result.emails = await emailHooks.onEmailsFetched.transform(result.emails);
const merged = [...scheduledEmails, ...result.emails.filter(email => !scheduledEmails.some(existing => existing.id === email.id))]; const merged = [...scheduledEmails, ...result.emails.filter(email => !scheduledEmails.some(existing => existing.id === email.id))];
const pendingUndoSend = get().pendingUndoSend; const pendingUndoSend = get().pendingUndoSend;
set({ set({