From 2fe865f1083ae88e7cb0e5d52e012b68543a422a Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 03:58:58 +0200 Subject: [PATCH] feat: tell plugins when the server accepted a sent or scheduled message, but not an undone one --- .../mail/__tests__/after-send-hook.test.tsx | 196 ++++++++++++++++++ components/mail/mail-app.tsx | 5 + components/pro/pro-compose-tab-body.tsx | 2 + lib/__tests__/plugin-after-send.test.ts | 46 ++++ lib/plugin-after-send.ts | 89 ++++++++ 5 files changed, 338 insertions(+) create mode 100644 components/mail/__tests__/after-send-hook.test.tsx create mode 100644 lib/__tests__/plugin-after-send.test.ts create mode 100644 lib/plugin-after-send.ts diff --git a/components/mail/__tests__/after-send-hook.test.tsx b/components/mail/__tests__/after-send-hook.test.tsx new file mode 100644 index 000000000..d91336608 --- /dev/null +++ b/components/mail/__tests__/after-send-hook.test.tsx @@ -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>>) => { + const Component = lazy(async () => ({ default: await load() })); + return function Dynamic(props: Record) { + return ; + }; +} })); +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 }) => ( + + ), +})); +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: () =>
message list
})); + +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(); + 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); +}); diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index baa676d44..c89510fa2 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -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); } diff --git a/components/pro/pro-compose-tab-body.tsx b/components/pro/pro-compose-tab-body.tsx index dc32b3357..84fdd5c90 100644 --- a/components/pro/pro-compose-tab-body.tsx +++ b/components/pro/pro-compose-tab-body.tsx @@ -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(); diff --git a/lib/__tests__/plugin-after-send.test.ts b/lib/__tests__/plugin-after-send.test.ts new file mode 100644 index 000000000..5e1f1a7bb --- /dev/null +++ b/lib/__tests__/plugin-after-send.test.ts @@ -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(); + }); +}); diff --git a/lib/plugin-after-send.ts b/lib/plugin-after-send.ts new file mode 100644 index 000000000..d6576f33a --- /dev/null +++ b/lib/plugin-after-send.ts @@ -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; 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(); +}