diff --git a/lib/demo/demo-client.ts b/lib/demo/demo-client.ts index 4e94555e..72436779 100644 --- a/lib/demo/demo-client.ts +++ b/lib/demo/demo-client.ts @@ -166,6 +166,19 @@ export class DemoJMAPClient implements IJMAPClient { return { emails, hasMore: position + limit < total, total }; } + async getSomeEmails(emailsId: string[], _accountId?: string): Promise { + 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 { return this.data.emails.filter(e => e.mailboxIds[mailboxId]); } diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index 5a7d4223..729c55f6 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -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 }>; getEmailsInMailbox(mailboxId: string): Promise; getEmail(emailId: string, accountId?: string): Promise; + getSomeEmails(emailsId: string[], accountId?: string): Promise getTagCounts(tagIds: string[]): Promise>; searchEmails(query: string, mailboxId?: string, accountId?: string, limit?: number, position?: number): Promise<{ emails: Email[]; hasMore: boolean; total: number }>; advancedSearchEmails( diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 387f2393..2b6d2f50 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -550,6 +550,43 @@ export class JMAPClient implements IJMAPClient { this.authHeader = `Bearer ${token}`; } + async getSomeEmails(emailsId: string[], accountId?: string): Promise { + 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). */ upgradeToBearer(accessToken: string, onRefresh?: () => Promise): void { this.authMode = 'bearer'; diff --git a/lib/plugin-hooks.ts b/lib/plugin-hooks.ts index 00ccc58c..02fb8107 100644 --- a/lib/plugin-hooks.ts +++ b/lib/plugin-hooks.ts @@ -272,6 +272,10 @@ export const emailHooks = { // normally. This is the send-takeover hook used by the S/MIME plugin to // replace the former native sign+encrypt+sendRaw pipeline. 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 diff --git a/stores/email-store.ts b/stores/email-store.ts index 0967f670..d44d0414 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -918,8 +918,9 @@ export const useEmailStore = create((set, get) => ({ for (const email of result.emails) { email.sourceFolder = resolveSourceFolderName(email, allMailMailboxes); } + const enrichedEmails = await emailHooks.onEmailsFetched.transform(result.emails); set({ - emails: annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId), + emails: annotateScheduledEmails(enrichedEmails, get().scheduledSubmissionByEmailId), hasMoreEmails: result.hasMore, totalEmails: result.total, isLoading: false, @@ -946,8 +947,9 @@ export const useEmailStore = create((set, get) => ({ // When filtering by tag, omit the mailbox constraint so emails across // all folders that carry the tag are returned. const result = await effectiveClient.getEmails(selectedKeyword ? undefined : jmapMailboxId, accountId, emailsPerPage, 0, keywordFilter, true); + const enrichedEmails = await emailHooks.onEmailsFetched.transform(result.emails); set({ - emails: annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId), + emails: annotateScheduledEmails(enrichedEmails, get().scheduledSubmissionByEmailId), hasMoreEmails: result.hasMore, totalEmails: result.total, // Clear thread caches since the email list was fully replaced @@ -991,8 +993,9 @@ export const useEmailStore = create((set, get) => ({ const currentEmails = get().emails; const existingIds = new Set(currentEmails.map(e => e.id)); const newEmails = result.emails.filter(e => !existingIds.has(e.id)); + const enrichedNewEmails = await emailHooks.onEmailsFetched.transform(newEmails); set({ - emails: [...currentEmails, ...newEmails], + emails: [...currentEmails, ...enrichedNewEmails], hasMoreEmails: result.hasMore, totalEmails: result.total, isLoadingMore: false, @@ -1034,8 +1037,9 @@ export const useEmailStore = create((set, get) => ({ const currentEmails = get().emails; const existingIds = new Set(currentEmails.map(e => e.id)); const newEmails = result.emails.filter(e => !existingIds.has(e.id)); + const enrichedNewEmails = await emailHooks.onEmailsFetched.transform(newEmails); set({ - emails: [...currentEmails, ...newEmails], + emails: [...currentEmails, ...enrichedNewEmails], hasMoreEmails: result.hasMore, totalEmails: result.total, isLoadingMore: false, @@ -1127,14 +1131,15 @@ export const useEmailStore = create((set, get) => ({ const existingIds = new Set(currentEmails.map(e => e.id)); const newEmails = annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId).filter((e: Email) => !existingIds.has(e.id)); + const enrichedNewEmails = await emailHooks.onEmailsFetched.transform(newEmails); set({ - emails: [...currentEmails, ...newEmails], + emails: [...currentEmails, ...enrichedNewEmails], hasMoreEmails: result.hasMore, totalEmails: result.total, isLoadingMore: false }); // Fetch full thread counts for newly loaded threads in the background - if (newEmails.length > 0) { + if (enrichedNewEmails.length > 0) { void get().fetchThreadEmailCounts(client); } } catch (error) { @@ -1754,60 +1759,66 @@ export const useEmailStore = create((set, get) => ({ searchEmails: async (client, query) => { set({ isLoading: true, error: null, searchQuery: query, emails: [], hasMoreEmails: false, totalEmails: 0 }); // Clear emails for loading state try { - const { isUnifiedView, unifiedRole, crossView } = get(); + const { isUnifiedView, unifiedRole, crossView, selectedMailbox, searchFilters } = get(); const emailsPerPage = useSettingsStore.getState().emailsPerPage; + let result; + let accountId; + let unifiedErrors; + if (isUnifiedView && crossView) { const includeGroup = useSettingsStore.getState().includeGroupInUnified; const built = await buildUnifiedAccountClients({ includeGroup }); - const result = await searchCrossViewEmails(built, crossView, query, emailsPerPage, 0); - const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query, filters: get().searchFilters }); - set({ - emails: result.emails, - externalSearchResults: externals, - hasMoreEmails: result.hasMore, - totalEmails: result.total, - isLoading: false, - unifiedErrors: result.errors, - }); - return; - } - - if (isUnifiedView && unifiedRole) { + result = await searchCrossViewEmails(built, crossView, query, emailsPerPage, 0); + unifiedErrors = result.errors; + + } else if (isUnifiedView && unifiedRole) { const includeGroup = useSettingsStore.getState().includeGroupInUnified; const built = await buildUnifiedAccountClients({ includeGroup }); - const result = await searchUnifiedEmails(built, unifiedRole, query, emailsPerPage, 0); - const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query, filters: get().searchFilters }); - set({ - emails: result.emails, - externalSearchResults: externals, - hasMoreEmails: result.hasMore, - totalEmails: result.total, - isLoading: false, - unifiedErrors: result.errors, - }); - return; + result = await searchUnifiedEmails(built, unifiedRole, query, emailsPerPage, 0); + unifiedErrors = result.errors; + + } else { + // Get the current mailbox to scope the search. In the All Mail view the + // search spans every folder of the account (no inMailbox constraint). + const isAllMail = selectedMailbox === ALL_MAIL_MAILBOX_ID; + const mailboxes = resolveActionMailboxes(); + 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 + 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 - // search spans every folder of the account (no inMailbox constraint). - const selectedMailbox = get().selectedMailbox; - const isAllMail = selectedMailbox === ALL_MAIL_MAILBOX_ID; - const mailboxes = resolveActionMailboxes(); - 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 hookEdit = await emailHooks.onSearchResults.transform({ + newEmailIds: [] as string[], + result: result, + query: query, + filters: searchFilters + }); - const result = await resolveActionClient(client).searchEmails(query, jmapMailboxId, accountId, emailsPerPage, 0); - const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query, filters: get().searchFilters }); + result = hookEdit.result; + 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({ emails: annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId), externalSearchResults: externals, hasMoreEmails: result.hasMore, totalEmails: result.total, - isLoading: false + isLoading: false, + ...(unifiedErrors ? { unifiedErrors } : {}) }); } catch (error) { set({ @@ -1841,60 +1852,64 @@ export const useEmailStore = create((set, get) => ({ try { const emailsPerPage = useSettingsStore.getState().emailsPerPage; + let result; + let accountId; + let unifiedErrors; if (isUnifiedView && crossView) { const includeGroup = useSettingsStore.getState().includeGroupInUnified; const built = await buildUnifiedAccountClients({ includeGroup }); - const result = await searchCrossViewEmails(built, crossView, searchQuery, emailsPerPage, 0); - if (controller.signal.aborted) return; - 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; - } + result = await searchCrossViewEmails(built, crossView, searchQuery, emailsPerPage, 0); + unifiedErrors = result.errors; - if (isUnifiedView && unifiedRole) { + } else if (isUnifiedView && unifiedRole) { const includeGroup = useSettingsStore.getState().includeGroupInUnified; const built = await buildUnifiedAccountClients({ includeGroup }); - const result = await advancedSearchUnifiedEmails( + result = await advancedSearchUnifiedEmails( built, unifiedRole, (mailboxId) => buildJMAPFilter(searchQuery, searchFilters, mailboxId), emailsPerPage, 0, ); - if (controller.signal.aborted) return; - 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, - searchAbortController: null, - unifiedErrors: result.errors, - }); - return; + unifiedErrors = result.errors; + + } else { + const isAllMail = selectedMailbox === ALL_MAIL_MAILBOX_ID; + const mailbox = mailboxes.find(mb => mb.id === selectedMailbox); + const jmapMailboxId = isAllMail ? undefined : (mailbox?.originalId || selectedMailbox); + accountId = isAllMail ? undefined : (mailbox?.isShared ? mailbox.accountId : undefined); + + const filter = buildJMAPFilter(searchQuery, searchFilters, jmapMailboxId); + result = await resolveActionClient(client).advancedSearchEmails(filter, accountId, emailsPerPage, 0); } - 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; - 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({ emails: annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId), externalSearchResults: externals, @@ -1902,6 +1917,7 @@ export const useEmailStore = create((set, get) => ({ totalEmails: result.total, isLoading: false, searchAbortController: null, + ...(unifiedErrors ? { unifiedErrors } : {}) }); } catch (error) { if (controller.signal.aborted) return; @@ -3284,6 +3300,7 @@ export const useEmailStore = create((set, get) => ({ try { const emailsPerPage = useSettingsStore.getState().emailsPerPage; 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 scheduledSubmissionByEmailId = new Map(result.emails.map(email => [email.id, { submissionId: email.emailSubmissionId, @@ -3324,6 +3341,7 @@ export const useEmailStore = create((set, get) => ({ try { const emailsPerPage = useSettingsStore.getState().emailsPerPage; 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 pendingUndoSend = get().pendingUndoSend; set({