diff --git a/stores/__tests__/email-store-scheduled-badge.test.ts b/stores/__tests__/email-store-scheduled-badge.test.ts new file mode 100644 index 000000000..0acde212d --- /dev/null +++ b/stores/__tests__/email-store-scheduled-badge.test.ts @@ -0,0 +1,87 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useEmailStore } from '../email-store'; +import { useAuthStore } from '../auth-store'; +import type { Email, Mailbox, ScheduledEmail } from '@/lib/jmap/types'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; + +// The scheduled badge and its Send now / Reschedule / Cancel buttons belong +// to the message the submission sends, in its own login and account. Email +// ids repeat across accounts: live on 0.16 and 1.0, a fresh login's scheduled +// message and the second message of its group's Inbox are both `iaaaaac`. + +function mailbox(id: string, role: string, accountId: string, shared = false): Mailbox { + return { + id: shared ? `${accountId}:${id}` : id, originalId: id, name: role, role, accountId, isShared: shared, + sortOrder: 0, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0, isSubscribed: true, + } as Mailbox; +} + +const scheduled = { + id: 'iaaaaac', threadId: 'c', subject: 'A scheduled one', receivedAt: '2026-10-26T10:00:00Z', + keywords: {}, mailboxIds: { e: true }, + scheduledSendAt: '2026-11-02T13:12:47Z', emailSubmissionId: 'b', scheduledIdentityId: 'c', + scheduledUndoStatus: 'pending', scheduledAccountId: 'cf', isScheduled: true, +} as unknown as ScheduledEmail; + +function row(overrides: Partial = {}): Email { + return { id: 'iaaaaac', threadId: 'c', subject: 'GRP two', receivedAt: '2026-10-26T10:00:00Z', keywords: {}, mailboxIds: { 'ce:a': true }, ...overrides } as Email; +} + +describe('scheduled badges in folder lists', () => { + let client: IJMAPClient; + + beforeEach(() => { + client = { + getAccountId: () => 'cf', + pruneEmailSubmissions: vi.fn().mockResolvedValue(undefined), + getScheduledEmails: vi.fn().mockResolvedValue({ emails: [scheduled], hasMore: false, total: 1, totalByAccount: { cf: 1 }, nextPosition: 1 }), + } as unknown as IJMAPClient; + useAuthStore.setState({ + activeAccountId: 'login-a', client: client as never, + getClientForAccount: (id: string) => (id === 'login-a' ? client : undefined) as never, + getAllConnectedClients: () => new Map([['login-a', client]]) as never, + } as never); + useEmailStore.setState({ + isScheduledView: false, isUnifiedView: false, viewingAccountId: null, + mailboxes: [mailbox('a', 'inbox', 'cf'), mailbox('e', 'sent', 'cf'), mailbox('a', 'inbox', 'ce', true)], + accountMailboxes: {}, searchQuery: '', searchMailboxId: '', + scheduledEmails: [], scheduledSubmissionByEmailId: new Map(), pendingUndoSend: null, + } as never); + }); + + it("leaves a group message with the id of the own scheduled message alone", async () => { + useEmailStore.setState({ selectedMailbox: 'ce:a', emails: [row()] } as never); + await useEmailStore.getState().refreshScheduledMetadata(client); + expect(useEmailStore.getState().emails[0].isScheduled).toBeFalsy(); + expect(useEmailStore.getState().emails[0].emailSubmissionId).toBeUndefined(); + }); + + it('marks the scheduled message itself in the own Sent folder', async () => { + useEmailStore.setState({ selectedMailbox: 'e', emails: [row({ subject: 'A scheduled one', mailboxIds: { e: true } })] } as never); + await useEmailStore.getState().refreshScheduledMetadata(client); + expect(useEmailStore.getState().emails[0]).toMatchObject({ isScheduled: true, emailSubmissionId: 'b', scheduledAccountId: 'cf' }); + }); + + it('tells the rows of a list that mixes accounts apart by their stamps', async () => { + useEmailStore.setState({ + isUnifiedView: true, selectedMailbox: '', + emails: [ + row({ sourceClientAccountId: 'login-a', sourceAccountId: 'ce' }), + row({ sourceClientAccountId: 'login-b', sourceAccountId: 'cf' }), + row({ subject: 'A scheduled one', sourceClientAccountId: 'login-a', sourceAccountId: 'cf' }), + ], + } as never); + await useEmailStore.getState().refreshScheduledMetadata(client); + expect(useEmailStore.getState().emails.map(e => !!e.isScheduled)).toEqual([false, false, true]); + }); + + it('opens a group row of a list that mixes accounts without the badge', async () => { + const groupRow = row({ sourceClientAccountId: 'login-a', sourceAccountId: 'ce' }); + (client as unknown as { getEmail: unknown }).getEmail = vi.fn().mockResolvedValue(row()); + useEmailStore.setState({ isUnifiedView: true, selectedMailbox: '', emails: [groupRow], selectedEmail: groupRow } as never); + await useEmailStore.getState().refreshScheduledMetadata(client); + const opened = await useEmailStore.getState().fetchEmailContent(client, 'iaaaaac'); + expect(opened?.isScheduled).toBeFalsy(); + expect(client.getEmail).toHaveBeenCalledWith('iaaaaac', 'ce'); + }); +}); diff --git a/stores/email-store.ts b/stores/email-store.ts index 423076d1d..c012cfcbb 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -243,6 +243,7 @@ interface EmailStore { // Scheduled send state scheduledEmails: ScheduledEmail[]; scheduledEmailIds: Set; + /** By login, account and email id (scheduledEmailKey). */ scheduledSubmissionByEmailId: Map; scheduledTotal: number; scheduledHasMore: boolean; @@ -603,11 +604,36 @@ function annotateScheduledEmails( return emails.map(email => annotateScheduledEmail(email, scheduledSubmissionByEmailId)); } +/** + * Key of a scheduled send in `scheduledSubmissionByEmailId`: the login and the + * JMAP account of the message, and its id. Email ids repeat across accounts, + * so by the bare id a row of another account got the badge, and its Send now, + * Reschedule and Cancel acted on this message's submission. + */ +function scheduledEmailKey(clientAccountId: string | undefined, accountId: string | undefined, emailId: string): string { + return `${clientAccountId ?? ''}|${accountId ?? ''}|${emailId}`; +} + +/** The scheduled sends `client` listed, by scheduledEmailKey. */ +function scheduledSubmissionMap(client: IJMAPClient, emails: ScheduledEmail[]): Map { + const auth = useAuthStore.getState(); + const login = [...auth.getAllConnectedClients()].find(([, c]) => c === client)?.[0] ?? auth.activeAccountId ?? undefined; + return new Map(emails.map(email => [scheduledEmailKey(login, email.scheduledAccountId ?? client.getAccountId(), email.id), { + submissionId: email.emailSubmissionId, + sendAt: email.scheduledSendAt, + identityId: email.scheduledIdentityId, + undoStatus: email.scheduledUndoStatus, + accountId: email.scheduledAccountId, + }])); +} + function annotateScheduledEmail( email: Email, scheduledSubmissionByEmailId: Map ): Email { - const scheduled = scheduledSubmissionByEmailId.get(email.id); + // The row's login and account: its stamps, else the folder on screen. + const { client, clientAccountId, accountId } = resolveEmailBlobContext(email, useAuthStore.getState().client); + const scheduled = scheduledSubmissionByEmailId.get(scheduledEmailKey(clientAccountId, accountId ?? client?.getAccountId(), email.id)); if (!scheduled) return email; return { ...email, @@ -3778,16 +3804,18 @@ export const useEmailStore = create((set, get) => ({ const email = await actionClient.getEmail(emailId, accountId); if (email) { - const annotatedEmail = annotateScheduledEmail(email, get().scheduledSubmissionByEmailId); // Email/get does not return client-only source metadata. Keep the source // captured before the await, including automatic selection after actions. + // Only carry stamps the list row has. A direct-folder row stays + // unstamped, since threadKeyFor scopes keys by these stamps. + const annotatedEmail = annotateScheduledEmail({ + ...email, + sourceClientAccountId: listEmail?.sourceClientAccountId, + sourceAccountId: listEmail?.sourceAccountId, + }, get().scheduledSubmissionByEmailId); annotatedEmail.accountId = listEmail?.accountId; annotatedEmail.accountLabel = listEmail?.accountLabel; annotatedEmail.sourceFolder = listEmail?.sourceFolder; - // Only carry stamps the list row has. A direct-folder row stays - // unstamped, since threadKeyFor scopes keys by these stamps. - annotatedEmail.sourceClientAccountId = listEmail?.sourceClientAccountId; - annotatedEmail.sourceAccountId = listEmail?.sourceAccountId; set({ selectedEmail: annotatedEmail }); return annotatedEmail; } @@ -7396,13 +7424,7 @@ export const useEmailStore = create((set, get) => ({ 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, - sendAt: email.scheduledSendAt, - identityId: email.scheduledIdentityId, - undoStatus: email.scheduledUndoStatus, - accountId: email.scheduledAccountId, - }])); + const scheduledSubmissionByEmailId = scheduledSubmissionMap(client, result.emails); const pendingUndoSend = get().pendingUndoSend; set({ scheduledEmails: result.emails, @@ -7443,13 +7465,7 @@ export const useEmailStore = create((set, get) => ({ set({ scheduledEmails: merged, scheduledEmailIds: new Set(merged.map(email => email.id)), - scheduledSubmissionByEmailId: new Map(merged.map(email => [email.id, { - submissionId: email.emailSubmissionId, - sendAt: email.scheduledSendAt, - identityId: email.scheduledIdentityId, - undoStatus: email.scheduledUndoStatus, - accountId: email.scheduledAccountId, - }])), + scheduledSubmissionByEmailId: scheduledSubmissionMap(client, merged), scheduledTotal: result.total, scheduledTotalByAccount: result.totalByAccount ?? {}, scheduledHasMore: result.hasMore, @@ -7484,13 +7500,7 @@ export const useEmailStore = create((set, get) => ({ position = page.nextPosition; } const pendingUndoSend = get().pendingUndoSend; - const scheduledSubmissionByEmailId = new Map(allEmails.map(email => [email.id, { - submissionId: email.emailSubmissionId, - sendAt: email.scheduledSendAt, - identityId: email.scheduledIdentityId, - undoStatus: email.scheduledUndoStatus, - accountId: email.scheduledAccountId, - }])); + const scheduledSubmissionByEmailId = scheduledSubmissionMap(client, allEmails); set({ scheduledEmails: get().isScheduledView ? allEmails : get().scheduledEmails, scheduledEmailIds: new Set(allEmails.map(email => email.id)),