mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-06 00:15:32 +00:00
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:
@@ -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
@@ -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)),
|
||||
|
||||
Reference in New Issue
Block a user