Files
webmail/stores/__tests__/email-store-undo-send.test.ts
T
Linus Rath c633185cda Merge branch 'main' into feat/stalwart-1.0
# Conflicts:
#	CONTRIBUTING.md
#	LICENSE
#	README.md
#	app/api/calendar-agenda/route.ts
#	app/api/wopi/files/[fileId]/contents/route.ts
#	app/api/wopi/launch/route.ts
#	components/calendar/calendar-app.tsx
#	components/calendar/calendar-day-view.tsx
#	components/calendar/calendar-month-view.tsx
#	components/calendar/calendar-week-view.tsx
#	components/calendar/event-modal.tsx
#	components/contacts/contact-detail.tsx
#	components/email/email-composer.tsx
#	components/email/email-list.tsx
#	components/files/file-browser.tsx
#	components/files/files-app.tsx
#	components/files/folder-tree-sidebar.tsx
#	components/filters/filter-rule-modal.tsx
#	components/mail/mail-app.tsx
#	hooks/use-mailbox-drop.ts
#	integration/run-tests.sh
#	lib/__tests__/calendar-agenda-route.test.ts
#	lib/__tests__/jmap-draft-lifecycle.test.ts
#	lib/__tests__/web-push.test.ts
#	lib/account-state-manager.ts
#	lib/jmap/client-interface.ts
#	lib/jmap/client.ts
#	lib/jmap/types.ts
#	lib/message-list-order.ts
#	lib/sieve/generator.ts
#	lib/sieve/parser.ts
#	lib/telemetry/payload.ts
#	lib/thread-utils.ts
#	lib/unified-mailbox.ts
#	lib/web-push.ts
#	lib/wopi/token.ts
#	locales/ar/common.json
#	locales/ca/common.json
#	locales/cs/common.json
#	locales/da/common.json
#	locales/de/common.json
#	locales/en/common.json
#	locales/es/common.json
#	locales/fa/common.json
#	locales/fr/common.json
#	locales/he/common.json
#	locales/hu/common.json
#	locales/it/common.json
#	locales/ja/common.json
#	locales/ko/common.json
#	locales/lv/common.json
#	locales/mn/common.json
#	locales/nb/common.json
#	locales/nl/common.json
#	locales/pl/common.json
#	locales/pt/common.json
#	locales/ro/common.json
#	locales/ru/common.json
#	locales/sk/common.json
#	locales/tr/common.json
#	locales/uk/common.json
#	locales/zh-TW/common.json
#	locales/zh/common.json
#	scripts/lite/lib.mjs
#	stores/__tests__/calendar-store-synthetic-ids.test.ts
#	stores/__tests__/email-store-shared-folder-keywords.test.ts
#	stores/__tests__/email-store-unified-actions.test.ts
#	stores/__tests__/file-store.test.ts
#	stores/email-store.ts
#	stores/file-store.ts
#	stores/message-list-tabs-store.ts
2026-10-12 08:35:08 +02:00

244 lines
12 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useEmailStore } from '../email-store';
import { SendCancelledCleanupError } from '@/lib/send-failure-message';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import type { Email, Mailbox } from '@/lib/jmap/types';
// "Undo send" and "Cancel and edit" cancel the submission, then put the
// message back in Drafts (or remove an S/MIME copy). Only a failed cancel may
// read as "undo failed": after a cancel that went through, the mail no longer
// goes out, whatever fails next.
function mailbox(id: string, role: string): Mailbox {
return { id, name: role, role, sortOrder: 0, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0, isSubscribed: true } as Mailbox;
}
const pending = {
submissionId: 'sub-1',
emailId: 'sent-1',
identityId: 'identity-1',
sendAt: new Date(Date.now() + 10_000).toISOString(),
isSmime: false,
};
const scheduled = {
id: 'sent-1',
emailSubmissionId: 'sub-1',
scheduledAccountId: 'account-1',
isSmimeScheduled: false,
} as unknown as Email;
describe('cancelling a send and restoring the draft', () => {
let client: IJMAPClient;
beforeEach(() => {
vi.restoreAllMocks();
vi.spyOn(console, 'error').mockImplementation(() => {});
client = {
getAccountId: vi.fn(() => 'account-1'),
pruneEmailSubmissions: vi.fn(() => new Promise<number>(() => {})),
getScheduledEmails: vi.fn().mockResolvedValue({ emails: [], hasMore: false, total: 0, nextPosition: 0 }),
cancelEmailSubmission: vi.fn().mockResolvedValue(undefined),
restoreEmailToDraft: vi.fn().mockResolvedValue(undefined),
deleteEmail: vi.fn().mockResolvedValue(undefined),
getEmail: vi.fn().mockResolvedValue({ id: 'sent-1' }),
getMailboxes: vi.fn().mockResolvedValue([]),
} as unknown as IJMAPClient;
useEmailStore.setState({
emails: [],
mailboxes: [mailbox('mb-drafts', 'drafts'), mailbox('mb-sent', 'sent')],
pendingUndoSend: pending,
});
});
it('undoes the send and returns the draft to open', async () => {
const restored = await useEmailStore.getState().cancelUndoSend(client, pending);
expect(client.cancelEmailSubmission).toHaveBeenCalledWith('sub-1', undefined);
expect(client.restoreEmailToDraft).toHaveBeenCalledWith('sent-1', 'mb-drafts', 'mb-sent');
expect(restored).toEqual({ id: 'sent-1' });
expect(useEmailStore.getState().pendingUndoSend).toBeNull();
});
it('lets a failed cancel through as it is: the mail may still go out', async () => {
vi.mocked(client.cancelEmailSubmission).mockRejectedValueOnce(new Error('The requested message is no longer in the queue.'));
const error = await useEmailStore.getState().cancelUndoSend(client, pending).catch((e: unknown) => e);
expect(error).not.toBeInstanceOf(SendCancelledCleanupError);
expect((error as Error).message).toBe('The requested message is no longer in the queue.');
expect(client.restoreEmailToDraft).not.toHaveBeenCalled();
});
it('says the send was cancelled when only putting the message back in Drafts failed', async () => {
vi.mocked(client.restoreEmailToDraft).mockRejectedValueOnce(new Error('Request failed: 503'));
const error = await useEmailStore.getState().cancelUndoSend(client, pending).catch((e: unknown) => e);
expect(error).toBeInstanceOf(SendCancelledCleanupError);
expect((error as SendCancelledCleanupError).step).toBe('restore');
// The undo toast is done with: the mail is not going out.
expect(useEmailStore.getState().pendingUndoSend).toBeNull();
});
it('says the send was cancelled when only removing an S/MIME copy failed', async () => {
vi.mocked(client.deleteEmail).mockRejectedValueOnce(new Error('Request failed: 503'));
const error = await useEmailStore.getState().cancelUndoSend(client, { ...pending, isSmime: true }).catch((e: unknown) => e);
expect(error).toBeInstanceOf(SendCancelledCleanupError);
expect((error as SendCancelledCleanupError).step).toBe('remove');
});
it('counts the undo as done when only reading the restored draft back failed', async () => {
vi.mocked(client.getEmail).mockRejectedValueOnce(new Error('Request failed: 503'));
await expect(useEmailStore.getState().cancelUndoSend(client, pending)).resolves.toBeNull();
expect(client.restoreEmailToDraft).toHaveBeenCalled();
});
it('reports "Cancel and edit" the same way', async () => {
vi.mocked(client.restoreEmailToDraft).mockRejectedValueOnce(new Error('Request failed: 503'));
const restoreFailed = await useEmailStore.getState().cancelScheduledEmailForEdit(client, scheduled).catch((e: unknown) => e);
expect(restoreFailed).toBeInstanceOf(SendCancelledCleanupError);
expect(client.cancelEmailSubmission).toHaveBeenCalledWith('sub-1', 'account-1');
vi.mocked(client.cancelEmailSubmission).mockRejectedValueOnce(new Error('cannotUnsend'));
const cancelFailed = await useEmailStore.getState().cancelScheduledEmailForEdit(client, scheduled).catch((e: unknown) => e);
expect(cancelFailed).not.toBeInstanceOf(SendCancelledCleanupError);
await expect(useEmailStore.getState().cancelScheduledEmailForEdit(client, scheduled)).resolves.toEqual({ id: 'sent-1' });
});
it('restores mail sent from the own identity in the own Drafts, as before', async () => {
const restored = await useEmailStore.getState().cancelUndoSend(client, { ...pending, submissionAccountId: 'account-1' });
expect(client.restoreEmailToDraft).toHaveBeenCalledWith('sent-1', 'mb-drafts', 'mb-sent', 'account-1');
expect(client.getMailboxes).not.toHaveBeenCalled();
expect(client.getEmail).toHaveBeenCalledWith('sent-1', 'account-1');
expect(restored).toEqual({ id: 'sent-1' });
});
});
// userb's session on the test servers: the own account `e` and the group
// team@example.org as account `c`. Mail sent from the team identity is created
// in `c` together with its submission. Email ids are per account and repeat:
// live, `eaaaaab` is userb's first own message and the team account's too (on
// Stalwart 1.0 and 0.16.23). The group's folders come back in a different
// order on the two versions; the ids are the same.
const GROUP_MAILBOXES = {
'1.0': [['a', 'inbox'], ['d', 'drafts'], ['c', 'junk'], ['b', 'trash'], ['e', 'sent']],
'0.16': [['e', 'sent'], ['d', 'drafts'], ['a', 'inbox'], ['c', 'junk'], ['b', 'trash']],
} as const;
type StoredEmail = { mailboxIds: Record<string, boolean>; keywords: Record<string, boolean>; subject: string };
/** A client for userb that keeps each account's messages apart, as the server does. */
function twoAccountClient(version: keyof typeof GROUP_MAILBOXES) {
const accounts: Record<string, Record<string, StoredEmail>> = {
e: { eaaaaab: { mailboxIds: { a: true }, keywords: { $seen: true }, subject: 'Unrelated message in the own inbox' } },
c: { eaaaaab: { mailboxIds: { e: true }, keywords: { $seen: true }, subject: 'Sent from team@example.org' } },
};
const at = (accountId?: string) => accounts[accountId ?? 'e'];
const client = {
getAccountId: () => 'e',
pruneEmailSubmissions: vi.fn(() => new Promise<number>(() => {})),
getScheduledEmails: vi.fn().mockResolvedValue({ emails: [], hasMore: false, total: 0, nextPosition: 0 }),
cancelEmailSubmission: vi.fn().mockResolvedValue(undefined),
getMailboxes: vi.fn(async (accountId?: string) => (accountId === 'c' ? GROUP_MAILBOXES[version] : GROUP_MAILBOXES['1.0'])
.map(([id, role]) => mailbox(id, role))),
restoreEmailToDraft: vi.fn(async (emailId: string, draftMailboxId: string, _sentMailboxId?: string, accountId?: string) => {
const email = at(accountId)[emailId];
if (!email) throw new Error('notFound');
email.mailboxIds = { [draftMailboxId]: true };
email.keywords = { ...email.keywords, $draft: true, $seen: true };
}),
deleteEmail: vi.fn(async (emailId: string, accountId?: string) => {
delete at(accountId)[emailId];
}),
getEmail: vi.fn(async (emailId: string, accountId?: string) => {
const email = at(accountId)[emailId];
return email ? { id: emailId, ...email } : null;
}),
};
return { client: client as unknown as IJMAPClient, accounts };
}
// The store's merged folder list: the own folders by their ids, the group's
// namespaced by account.
const OWN_AND_GROUP_FOLDERS = [
mailbox('a', 'inbox'), mailbox('d', 'drafts'), mailbox('e', 'sent'),
{ ...mailbox('c:d', 'drafts'), originalId: 'd', accountId: 'c', isShared: true },
{ ...mailbox('c:e', 'sent'), originalId: 'e', accountId: 'c', isShared: true },
] as Mailbox[];
describe.each(['1.0', '0.16'] as const)('undoing a send from a group identity on Stalwart %s', (version) => {
const groupPending = {
submissionId: 'sub-team',
emailId: 'eaaaaab',
identityId: 'b',
sendAt: new Date(Date.now() + 10_000).toISOString(),
isSmime: false,
submissionAccountId: 'c',
};
beforeEach(() => {
vi.restoreAllMocks();
vi.spyOn(console, 'error').mockImplementation(() => {});
useEmailStore.setState({ emails: [], mailboxes: OWN_AND_GROUP_FOLDERS, pendingUndoSend: groupPending });
});
it('puts the message back in the group account\'s Drafts and leaves the own message with the same id alone', async () => {
const { client, accounts } = twoAccountClient(version);
const restored = await useEmailStore.getState().cancelUndoSend(client, groupPending);
expect(client.cancelEmailSubmission).toHaveBeenCalledWith('sub-team', 'c');
expect(client.getMailboxes).toHaveBeenCalledWith('c');
expect(client.restoreEmailToDraft).toHaveBeenCalledWith('eaaaaab', 'd', 'e', 'c');
expect(accounts.c.eaaaaab).toEqual({
mailboxIds: { d: true }, keywords: { $seen: true, $draft: true }, subject: 'Sent from team@example.org',
});
expect(accounts.e.eaaaaab).toEqual({
mailboxIds: { a: true }, keywords: { $seen: true }, subject: 'Unrelated message in the own inbox',
});
// The draft handed to the composer is the group's message.
expect(client.getEmail).toHaveBeenCalledWith('eaaaaab', 'c');
expect(restored).toMatchObject({ id: 'eaaaaab', subject: 'Sent from team@example.org' });
});
it('"Cancel and edit" restores a scheduled group message in the group account', async () => {
const { client, accounts } = twoAccountClient(version);
const scheduledTeam = { id: 'eaaaaab', emailSubmissionId: 'sub-team', scheduledAccountId: 'c', isSmimeScheduled: false } as unknown as Email;
const restored = await useEmailStore.getState().cancelScheduledEmailForEdit(client, scheduledTeam);
expect(client.cancelEmailSubmission).toHaveBeenCalledWith('sub-team', 'c');
expect(accounts.c.eaaaaab.mailboxIds).toEqual({ d: true });
expect(accounts.e.eaaaaab.mailboxIds).toEqual({ a: true });
expect(accounts.e.eaaaaab.keywords.$draft).toBeUndefined();
expect(restored).toMatchObject({ subject: 'Sent from team@example.org' });
});
it('"Cancel and compose again" removes an S/MIME group message in the group account only', async () => {
const { client, accounts } = twoAccountClient(version);
const scheduledTeam = { id: 'eaaaaab', emailSubmissionId: 'sub-team', scheduledAccountId: 'c', isSmimeScheduled: true } as unknown as Email;
await expect(useEmailStore.getState().cancelScheduledEmailForEdit(client, scheduledTeam)).resolves.toBeNull();
expect(client.deleteEmail).toHaveBeenCalledWith('eaaaaab', 'c');
expect(accounts.c.eaaaaab).toBeUndefined();
expect(accounts.e.eaaaaab.subject).toBe('Unrelated message in the own inbox');
});
it('reports a restore the group account refused as a cancelled send, touching nothing else', async () => {
const { client, accounts } = twoAccountClient(version);
delete accounts.c.eaaaaab;
const error = await useEmailStore.getState().cancelUndoSend(client, groupPending).catch((e: unknown) => e);
expect(error).toBeInstanceOf(SendCancelledCleanupError);
expect(accounts.e.eaaaaab.mailboxIds).toEqual({ a: true });
});
});