mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
feat: tell plugins when the server accepted a sent or scheduled message, but not an undone one
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
import React, { Suspense, lazy } from 'react';
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Mailbox } from '@/lib/jmap/types';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { usePluginStore } from '@/stores/plugin-store';
|
||||
import { useThemeStore } from '@/stores/theme-store';
|
||||
import { useToastStore } from '@/stores/toast-store';
|
||||
import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
|
||||
import { emailHooks, clearAllHooks } from '@/lib/plugin-hooks';
|
||||
import { resetAnnouncedSends } from '@/lib/plugin-after-send';
|
||||
|
||||
// onAfterEmailSend: once the server accepted the message, never for a failed
|
||||
// send or one taken back with Undo.
|
||||
|
||||
vi.mock('next/dynamic', () => ({ default: (load: () => Promise<React.ComponentType<Record<string, unknown>>>) => {
|
||||
const Component = lazy(async () => ({ default: await load() }));
|
||||
return function Dynamic(props: Record<string, unknown>) {
|
||||
return <Suspense fallback={null}><Component {...props} /></Suspense>;
|
||||
};
|
||||
} }));
|
||||
const sendData = vi.hoisted(() => ({ delayedUntil: undefined as string | undefined, error: null as unknown }));
|
||||
vi.mock('@/components/email/email-composer', () => ({
|
||||
EmailComposer: ({ onSend }: { onSend?: (data: unknown) => Promise<void> }) => (
|
||||
<button onClick={() => {
|
||||
onSend?.({ to: ['bob@example.com'], cc: ['carol@example.com'], bcc: [], subject: 'Hi', body: 'Hello', delayedUntil: sendData.delayedUntil })
|
||||
.catch((e) => { sendData.error = e; });
|
||||
}}>
|
||||
stub send
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
vi.mock('@/components/layout/sidebar', () => ({ Sidebar: () => null }));
|
||||
vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null }));
|
||||
vi.mock('@/components/layout/calendar-event-notification-toaster', () => ({ CalendarEventNotificationToaster: () => null }));
|
||||
vi.mock('@/components/layout/share-notification-toaster', () => ({ ShareNotificationToaster: () => null }));
|
||||
vi.mock('@/components/layout/mobile-header', () => ({ MobileHeader: () => null }));
|
||||
vi.mock('@/components/email/message-list-tabs', () => ({ MessageListTabs: () => null }));
|
||||
vi.mock('@/components/search/search-box', () => ({ SearchBox: () => null }));
|
||||
vi.mock('@/components/tour/tour-provider', () => ({ useTour: () => ({ activeTour: null, registerStep: vi.fn() }) }));
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||
vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] }));
|
||||
vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail' }) }));
|
||||
vi.mock('@/hooks/use-identity-sync', () => ({ useIdentitySync: () => {} }));
|
||||
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ useProMultiAccountIdentities: () => [] }));
|
||||
vi.mock('@/hooks/use-pro-multi-account-mailboxes', () => ({ useProMultiAccountMailboxes: () => {} }));
|
||||
vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => null, canWopiOpen: () => false }));
|
||||
vi.mock('@/hooks/use-browser-navigation', () => ({ useBrowserNavigation: () => {} }));
|
||||
vi.mock('@/hooks/use-media-query', () => ({
|
||||
useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }),
|
||||
useMediaQuery: () => false,
|
||||
useIsDesktop: () => true,
|
||||
}));
|
||||
vi.mock('@/lib/push-bindings', () => ({ reconcilePushBindings: () => {}, releasePushBindings: () => {} }));
|
||||
vi.mock('@/components/email/email-list', () => ({ EmailList: () => <div>message list</div> }));
|
||||
|
||||
const { MailApp } = await import('../mail-app');
|
||||
|
||||
const inbox = { id: 'inbox', name: 'Inbox', role: 'inbox' } as Mailbox;
|
||||
const cancelUndoSend = vi.fn();
|
||||
const sendEmail = vi.fn();
|
||||
const refreshScheduledMetadata = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request')));
|
||||
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} });
|
||||
usePluginStore.setState({ initializePlugins: vi.fn().mockResolvedValue(undefined) });
|
||||
useThemeStore.setState({ syncServerThemes: vi.fn().mockResolvedValue(undefined) });
|
||||
useToastStore.setState({ toasts: [] });
|
||||
const client = {
|
||||
getAccountId: () => 'acc', getUsername: () => 'me@example.com',
|
||||
getSieveAccountId: () => null, hasDelayedSend: () => true, getSession: () => null,
|
||||
};
|
||||
useAuthStore.setState({
|
||||
isAuthenticated: true, isLoading: false, client: client as never, activeAccountId: 'login', identities: [],
|
||||
getClientForAccount: () => client as never,
|
||||
getAllConnectedClients: () => new Map([['login', client]]) as never,
|
||||
});
|
||||
useSettingsStore.setState({ enableUnifiedMailbox: false, mailLayout: 'split', sendDelaySeconds: 30 } as never);
|
||||
cancelUndoSend.mockReset();
|
||||
sendEmail.mockReset();
|
||||
clearAllHooks();
|
||||
resetAnnouncedSends();
|
||||
sendData.delayedUntil = undefined;
|
||||
sendData.error = null;
|
||||
refreshScheduledMetadata.mockReset().mockResolvedValue(undefined);
|
||||
useEmailStore.setState({
|
||||
emails: [], selectedEmail: null, selectedMailbox: inbox.id, mailboxes: [inbox],
|
||||
viewingAccountId: null, accountMailboxes: {}, isUnifiedView: false, unifiedRole: null,
|
||||
isScheduledView: false, isLoadingEmail: false, isLoading: false, searchQuery: '',
|
||||
searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: '', selectedKeyword: null,
|
||||
selectedEmailKeys: new Set(),
|
||||
fetchMailboxes: vi.fn().mockResolvedValue(undefined), fetchEmails: vi.fn().mockResolvedValue(undefined),
|
||||
fetchQuota: vi.fn().mockResolvedValue(undefined), fetchTagCounts: vi.fn().mockResolvedValue(undefined),
|
||||
refreshScheduledMetadata,
|
||||
cancelUndoSend,
|
||||
sendEmail,
|
||||
clearPendingUndoSend: () => useEmailStore.setState({ pendingUndoSend: null }),
|
||||
pendingUndoSend: null,
|
||||
} as never);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
|
||||
async function sendFromComposer() {
|
||||
render(<MailApp />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'sidebar.compose' }));
|
||||
fireEvent.click(await screen.findByText('stub send', {}, { timeout: 5000 }));
|
||||
await waitFor(() => expect(sendEmail).toHaveBeenCalled());
|
||||
}
|
||||
|
||||
const undoToast = () => useToastStore.getState().toasts.find((t) => t.action?.label === 'email_viewer.undo_send');
|
||||
|
||||
describe('onAfterEmailSend', () => {
|
||||
it('reports a send the server accepted', async () => {
|
||||
useSettingsStore.setState({ sendDelaySeconds: 0 } as never);
|
||||
const sent = vi.fn();
|
||||
emailHooks.onAfterEmailSend.register('test', sent);
|
||||
sendEmail.mockResolvedValue({ scheduled: false, emailId: 'e1', emailSubmissionId: 's1' });
|
||||
|
||||
await sendFromComposer();
|
||||
|
||||
await waitFor(() => expect(sent).toHaveBeenCalledTimes(1));
|
||||
expect(sent).toHaveBeenCalledWith({
|
||||
emailId: 'e1', submissionId: 's1', to: ['bob@example.com'], cc: ['carol@example.com'], bcc: [],
|
||||
subject: 'Hi', scheduledFor: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('says nothing when the send fails', async () => {
|
||||
const sent = vi.fn();
|
||||
emailHooks.onAfterEmailSend.register('test', sent);
|
||||
sendEmail.mockRejectedValue(new Error('refused'));
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
await sendFromComposer();
|
||||
await waitFor(() => expect(sendData.error).not.toBeNull());
|
||||
|
||||
expect(sent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports a scheduled send at once, with its time', async () => {
|
||||
const sent = vi.fn();
|
||||
emailHooks.onAfterEmailSend.register('test', sent);
|
||||
sendData.delayedUntil = '2030-01-01T08:00:00.000Z';
|
||||
sendEmail.mockResolvedValue({ scheduled: true, emailId: 'e2', emailSubmissionId: 's2', sendAt: '2030-01-01T08:00:00Z' });
|
||||
|
||||
await sendFromComposer();
|
||||
|
||||
await waitFor(() => expect(sent).toHaveBeenCalledTimes(1));
|
||||
expect(sent).toHaveBeenCalledWith(expect.objectContaining({ emailId: 'e2', scheduledFor: '2030-01-01T08:00:00Z' }));
|
||||
});
|
||||
|
||||
it('waits out the undo window, and says nothing when the send is undone', async () => {
|
||||
const sent = vi.fn();
|
||||
emailHooks.onAfterEmailSend.register('test', sent);
|
||||
let sendAt = '';
|
||||
sendEmail.mockImplementation(async () => {
|
||||
sendAt = new Date(Date.now() + 1500).toISOString();
|
||||
useEmailStore.setState({
|
||||
pendingUndoSend: { submissionId: 's3', emailId: 'e3', identityId: 'id-1', sendAt, isSmime: false },
|
||||
} as never);
|
||||
return { scheduled: true, emailId: 'e3', emailSubmissionId: 's3', sendAt };
|
||||
});
|
||||
cancelUndoSend.mockResolvedValue(null);
|
||||
|
||||
await sendFromComposer();
|
||||
expect(sent).not.toHaveBeenCalled();
|
||||
|
||||
await waitFor(() => expect(undoToast()).toBeDefined());
|
||||
act(() => { undoToast()!.action!.onClick(); });
|
||||
await waitFor(() => expect(cancelUndoSend).toHaveBeenCalled());
|
||||
|
||||
await act(() => new Promise((r) => setTimeout(r, 1800)));
|
||||
expect(sent).not.toHaveBeenCalled();
|
||||
}, 15_000);
|
||||
|
||||
it('reports a held send once the undo window is over', async () => {
|
||||
const sent = vi.fn();
|
||||
emailHooks.onAfterEmailSend.register('test', sent);
|
||||
sendEmail.mockImplementation(async () => (
|
||||
{ scheduled: true, emailId: 'e4', emailSubmissionId: 's4', sendAt: new Date(Date.now() + 1000).toISOString() }
|
||||
));
|
||||
|
||||
await sendFromComposer();
|
||||
expect(sent).not.toHaveBeenCalled();
|
||||
|
||||
await waitFor(() => expect(sent).toHaveBeenCalledTimes(1), { timeout: 3000 });
|
||||
expect(sent).toHaveBeenCalledWith(expect.objectContaining({ emailId: 'e4', submissionId: 's4', scheduledFor: null }));
|
||||
}, 15_000);
|
||||
});
|
||||
@@ -119,6 +119,7 @@ import { plainTextToComposerBody, getQuoteBodies } from "@/lib/email-composer-ut
|
||||
import { appLifecycleHooks, uiHooks, routerHooks, toastHooks, emailHooks } from "@/lib/plugin-hooks";
|
||||
import { emailToReadView } from "@/lib/plugin-projection";
|
||||
import { emitThreadExpand } from "@/lib/plugin-thread-expand";
|
||||
import { announceSent, cancelAnnouncedSend, releaseAnnouncedSend } from "@/lib/plugin-after-send";
|
||||
import { composeOptionsFor, runBeforeCompose, type ComposePrefill, type ComposerOpenMode } from "@/lib/plugin-compose-gate";
|
||||
import { formatMailto } from "@/lib/protocol-handlers/mailto";
|
||||
import { buildQuoteHeader } from "@/lib/quote-header";
|
||||
@@ -1971,6 +1972,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
|
||||
const result = await sendEmail(sendClient, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody, data.attachments, data.inReplyTo, data.references, data.delayedUntil, data.envelopeMailFrom, { requestReadReceipt: data.requestReadReceipt, requestDsn: data.requestDsn, requireTls: data.requireTls, localAccountId: data.localAccountId });
|
||||
submitted = true;
|
||||
announceSent(result, data);
|
||||
setShowComposer(false);
|
||||
// Sending an edited draft destroys it server-side - and every autosave
|
||||
// before that already replaced it under a fresh id (createDraft is a
|
||||
@@ -2297,6 +2299,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
pending.submissionAccountId,
|
||||
);
|
||||
clearPendingUndoSend();
|
||||
releaseAnnouncedSend(pending.submissionId);
|
||||
if (isScheduledView) await fetchScheduledEmails(client);
|
||||
} catch (error) {
|
||||
console.error('Failed to send now:', error);
|
||||
@@ -2311,6 +2314,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
void (async () => {
|
||||
try {
|
||||
const restored = await cancelUndoSend(undoClient, pending);
|
||||
// Never left the server: plugins don't hear about it.
|
||||
cancelAnnouncedSend(pending.submissionId);
|
||||
if (restored && !pending.isSmime) {
|
||||
await handleEditDraft(restored);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { toast } from "@/stores/toast-store";
|
||||
import { useProTabStore, registerProTabCloseInterceptor, type ProComposeTabData } from "@/stores/pro-tab-store";
|
||||
import { debug } from "@/lib/debug";
|
||||
import { threadKeyFor } from "@/lib/thread-utils";
|
||||
import { announceSent } from "@/lib/plugin-after-send";
|
||||
|
||||
interface ProComposeTabBodyProps {
|
||||
tabId: string;
|
||||
@@ -89,6 +90,7 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
|
||||
{ localAccountId: sendData.localAccountId },
|
||||
);
|
||||
submitted = true;
|
||||
announceSent(result, sendData);
|
||||
|
||||
if (result.scheduled) {
|
||||
await handleScheduledSendCreated();
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// @vitest-environment node
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { emailHooks, clearAllHooks } from '../plugin-hooks';
|
||||
import { announceSent, cancelAnnouncedSend, releaseAnnouncedSend, resetAnnouncedSends } from '../plugin-after-send';
|
||||
|
||||
const message = { to: ['bob@example.com'], cc: [], bcc: [], subject: 'Hi' };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
clearAllHooks();
|
||||
resetAnnouncedSends();
|
||||
});
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe('announceSent for a send held back for undo', () => {
|
||||
const held = () => ({ scheduled: true, emailId: 'e1', emailSubmissionId: 's1', sendAt: new Date(Date.now() + 10_000).toISOString() });
|
||||
|
||||
it('reports when the hold runs out', () => {
|
||||
const sent = vi.fn();
|
||||
emailHooks.onAfterEmailSend.register('t', sent);
|
||||
announceSent(held(), message);
|
||||
vi.advanceTimersByTime(9_000);
|
||||
expect(sent).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(1_000);
|
||||
expect(sent).toHaveBeenCalledWith({ emailId: 'e1', submissionId: 's1', ...message, scheduledFor: null });
|
||||
});
|
||||
|
||||
it('reports at once on "Send now"', () => {
|
||||
const sent = vi.fn();
|
||||
emailHooks.onAfterEmailSend.register('t', sent);
|
||||
announceSent(held(), message);
|
||||
releaseAnnouncedSend('s1');
|
||||
expect(sent).toHaveBeenCalledTimes(1);
|
||||
vi.advanceTimersByTime(20_000);
|
||||
expect(sent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('never reports an undone send', () => {
|
||||
const sent = vi.fn();
|
||||
emailHooks.onAfterEmailSend.register('t', sent);
|
||||
announceSent(held(), message);
|
||||
cancelAnnouncedSend('s1');
|
||||
vi.advanceTimersByTime(20_000);
|
||||
expect(sent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
// onAfterEmailSend: told once the server accepted a message.
|
||||
//
|
||||
// - A plain send is reported straight away, scheduledFor null.
|
||||
// - A send the user scheduled for later is reported straight away too, with
|
||||
// scheduledFor: the server accepted it, even though it goes out later.
|
||||
// - A send held back for undo ("Sending in 10 s") is reported only when the
|
||||
// hold runs out or the user picks "Send now". Undo drops it: that message
|
||||
// never leaves.
|
||||
// A failed send never gets here; the callers only report a resolved send.
|
||||
|
||||
import type { SendEmailResult } from './jmap/types';
|
||||
import { emailHooks } from './plugin-hooks';
|
||||
|
||||
export interface SentMessage {
|
||||
to: string[];
|
||||
cc: string[];
|
||||
bcc: string[];
|
||||
subject: string;
|
||||
/** Set when the user scheduled the message; absent for an undo-send hold. */
|
||||
delayedUntil?: string;
|
||||
}
|
||||
|
||||
interface AfterSendPayload {
|
||||
emailId: string | null;
|
||||
submissionId: string | null;
|
||||
to: string[];
|
||||
cc: string[];
|
||||
bcc: string[];
|
||||
subject: string;
|
||||
scheduledFor: string | null;
|
||||
}
|
||||
|
||||
const held = new Map<string, { timer: ReturnType<typeof setTimeout>; payload: AfterSendPayload }>();
|
||||
|
||||
function emit(payload: AfterSendPayload): void {
|
||||
if (emailHooks.onAfterEmailSend.size === 0) return;
|
||||
void emailHooks.onAfterEmailSend.emit(payload);
|
||||
}
|
||||
|
||||
/** Report a send the server accepted (see the rules above). */
|
||||
export function announceSent(result: SendEmailResult, message: SentMessage): void {
|
||||
const payload: AfterSendPayload = {
|
||||
emailId: result.emailId ?? null,
|
||||
submissionId: result.emailSubmissionId ?? null,
|
||||
to: [...message.to],
|
||||
cc: [...message.cc],
|
||||
bcc: [...message.bcc],
|
||||
subject: message.subject,
|
||||
scheduledFor: null,
|
||||
};
|
||||
if (message.delayedUntil) {
|
||||
emit({ ...payload, scheduledFor: result.sendAt ?? message.delayedUntil });
|
||||
return;
|
||||
}
|
||||
const submissionId = result.emailSubmissionId;
|
||||
if (result.scheduled && submissionId && result.sendAt) {
|
||||
const wait = Math.max(0, Date.parse(result.sendAt) - Date.now());
|
||||
const timer = setTimeout(() => {
|
||||
held.delete(submissionId);
|
||||
emit(payload);
|
||||
}, Number.isFinite(wait) ? wait : 0);
|
||||
held.set(submissionId, { timer, payload });
|
||||
return;
|
||||
}
|
||||
emit(payload);
|
||||
}
|
||||
|
||||
/** The user undid a held send: it is not reported. */
|
||||
export function cancelAnnouncedSend(submissionId: string): void {
|
||||
const entry = held.get(submissionId);
|
||||
if (!entry) return;
|
||||
clearTimeout(entry.timer);
|
||||
held.delete(submissionId);
|
||||
}
|
||||
|
||||
/** The user chose "Send now" for a held send: report it now. */
|
||||
export function releaseAnnouncedSend(submissionId: string): void {
|
||||
const entry = held.get(submissionId);
|
||||
if (!entry) return;
|
||||
clearTimeout(entry.timer);
|
||||
held.delete(submissionId);
|
||||
emit(entry.payload);
|
||||
}
|
||||
|
||||
/** Drop every held send (tests). */
|
||||
export function resetAnnouncedSends(): void {
|
||||
for (const { timer } of held.values()) clearTimeout(timer);
|
||||
held.clear();
|
||||
}
|
||||
Reference in New Issue
Block a user