fix: show the scheduled badge and its buttons only on the scheduled message, not on another account's message with the same id

This commit is contained in:
Linus Rath
2026-10-26 16:11:51 +01:00
parent acac3128bc
commit ea6d414e5b
2 changed files with 124 additions and 27 deletions
@@ -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> = {}): 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');
});
});
+37 -27
View File
@@ -243,6 +243,7 @@ interface EmailStore {
// Scheduled send state
scheduledEmails: ScheduledEmail[];
scheduledEmailIds: Set<string>;
/** By login, account and email id (scheduledEmailKey). */
scheduledSubmissionByEmailId: Map<string, ScheduledSubmissionMetadata>;
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<string, ScheduledSubmissionMetadata> {
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<string, ScheduledSubmissionMetadata>
): 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<EmailStore>((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<EmailStore>((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<EmailStore>((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<EmailStore>((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)),