mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
Merge branch 'hooks/mailui' into feat/plugin-system-audit
This commit is contained in:
@@ -0,0 +1,314 @@
|
||||
import { render, screen, fireEvent, act, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import React, { useState } from 'react';
|
||||
import { EmailComposer } from '../email-composer';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { emailHooks, clearAllHooks } from '@/lib/plugin-hooks';
|
||||
import { resetComposerSessions } from '@/lib/plugin-composer-hooks';
|
||||
|
||||
// mail-compose hooks fired by the composer itself: onComposerOpen and
|
||||
// onComposerClose once per session (not per remount), onDraftAutoSave after a
|
||||
// save the server took, and the onBeforeCompose prefill the host hands in.
|
||||
// The hook module is the real one here.
|
||||
|
||||
// ─── Heavy component mocks (mirrors reply-addressing.test.tsx) ────────────────
|
||||
|
||||
const editorKeyDown = vi.hoisted(() => vi.fn());
|
||||
const toastError = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/components/email/rich-text-editor', () => ({
|
||||
RichTextEditor: () => React.createElement('div', {
|
||||
'data-testid': 'rich-text-editor',
|
||||
onKeyDown: (event: React.KeyboardEvent) => {
|
||||
if (!event.defaultPrevented) editorKeyDown();
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||
vi.mock('@/components/ui/avatar', () => ({ Avatar: () => null }));
|
||||
vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null }));
|
||||
vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null }));
|
||||
vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null }));
|
||||
vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
|
||||
vi.mock('@/hooks/use-focus-trap', () => ({
|
||||
useFocusTrap: () => ({ current: null }),
|
||||
}));
|
||||
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({
|
||||
useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }),
|
||||
stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }),
|
||||
}));
|
||||
|
||||
// ─── Store mocks ──────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('@/stores/auth-store', () => {
|
||||
const state = {
|
||||
client: null,
|
||||
identities: [],
|
||||
primaryIdentity: null,
|
||||
isAuthenticated: false,
|
||||
isDemoMode: false,
|
||||
activeAccountId: null,
|
||||
connectionLost: false,
|
||||
getClientForAccount: () => undefined,
|
||||
getAllConnectedClients: () => new Map(),
|
||||
syncIdentities: () => {},
|
||||
refreshIdentities: async () => {},
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useAuthStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/identity-store', () => {
|
||||
const state = {
|
||||
identities: [{ id: 'id-me', email: 'me@example.com', name: 'Me' }],
|
||||
defaultIdentityId: 'id-me',
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useIdentityStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/account-store', () => {
|
||||
const state = { accounts: [], getAccountById: () => undefined };
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useAccountStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/email-store', () => {
|
||||
const state = {
|
||||
draftSaveEnabled: false,
|
||||
sendRawEmail: async () => ({ sent: true }),
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useEmailStore: hook };
|
||||
});
|
||||
|
||||
const updateSetting = vi.fn();
|
||||
|
||||
vi.mock('@/stores/settings-store', () => {
|
||||
const state = {
|
||||
timeFormat: '24h',
|
||||
plainTextMode: false,
|
||||
subAddressDelimiter: '+',
|
||||
autoSelectReplyIdentity: true,
|
||||
attachmentReminderEnabled: false,
|
||||
attachmentReminderKeywords: [],
|
||||
emptySubjectWarningEnabled: true,
|
||||
sendDelaySeconds: 0,
|
||||
signaturePosition: 'above_quote',
|
||||
signatureSeparatorEnabled: false,
|
||||
requestReadReceiptDefault: false,
|
||||
addTrustedSender: () => {},
|
||||
trustedSendersAddressBook: null,
|
||||
updateSetting: (...args: unknown[]) => updateSetting(...args),
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useSettingsStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/contact-store', () => {
|
||||
const state = {
|
||||
contacts: [],
|
||||
getAutocomplete: async () => [],
|
||||
addToTrustedSendersBook: async () => {},
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useContactStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/template-store', () => {
|
||||
const state = { templates: [], addTemplate: async () => {} };
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useTemplateStore: hook };
|
||||
});
|
||||
|
||||
// ─── Misc dependency mocks ────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('@/stores/toast-store', () => ({
|
||||
toast: { info: () => {}, error: toastError, success: () => {} },
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/email-sanitization', () => ({
|
||||
sanitizeSignatureHtml: (v: string) => v,
|
||||
sanitizeEmailHtml: (v: string) => v,
|
||||
parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/email-threading', () => ({
|
||||
computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
|
||||
}));
|
||||
vi.mock('@/lib/signature-utils', () => ({
|
||||
appendPlainTextSignature: (body: string) => body,
|
||||
getPlainTextSignature: () => '',
|
||||
}));
|
||||
vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' }));
|
||||
vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
|
||||
vi.mock('@/components/email/quoted-html', () => ({
|
||||
buildQuotedHtmlBlock: () => '',
|
||||
serializeEditorContent: () => '',
|
||||
}));
|
||||
vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s }));
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const DRAFT = {
|
||||
to: 'bob@example.com',
|
||||
cc: '',
|
||||
bcc: '',
|
||||
subject: 'Lunch',
|
||||
body: '<p>Hello there</p>',
|
||||
showCc: false,
|
||||
showBcc: false,
|
||||
selectedIdentityId: 'id-me',
|
||||
subAddressTag: '',
|
||||
mode: 'compose' as const,
|
||||
draftId: null,
|
||||
};
|
||||
|
||||
const flushCloseTimer = () => act(() => new Promise((r) => setTimeout(r, 10)));
|
||||
|
||||
const requestCloseRef = { current: null } as React.MutableRefObject<((afterClose?: () => void) => void) | null>;
|
||||
|
||||
function Host({ onSend, sessionKey = 's1' }: { onSend?: () => Promise<void>; sessionKey?: string }) {
|
||||
const [open, setOpen] = useState(true);
|
||||
if (!open) return <p>closed</p>;
|
||||
return (
|
||||
<EmailComposer
|
||||
sessionKey={sessionKey}
|
||||
requestCloseRef={requestCloseRef}
|
||||
initialData={DRAFT}
|
||||
onClose={() => setOpen(false)}
|
||||
onSend={async () => {
|
||||
setOpen(false);
|
||||
await onSend?.();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
clearAllHooks();
|
||||
resetComposerSessions();
|
||||
useAuthStore.setState({ client: null });
|
||||
});
|
||||
|
||||
describe('onComposerOpen / onComposerClose', () => {
|
||||
it('reports the open with the mode and the original message', () => {
|
||||
const opened = vi.fn();
|
||||
emailHooks.onComposerOpen.register('test', opened);
|
||||
render(
|
||||
<EmailComposer
|
||||
mode="reply"
|
||||
sessionKey="r1"
|
||||
sourceEmailId="orig-1"
|
||||
replyTo={{ from: [{ email: 'alice@example.com' }], subject: 'Plans' }}
|
||||
/>,
|
||||
);
|
||||
expect(opened).toHaveBeenCalledTimes(1);
|
||||
expect(opened).toHaveBeenCalledWith({ mode: 'reply', inReplyToId: 'orig-1', originalSubject: 'Plans' });
|
||||
});
|
||||
|
||||
it('treats a remount with the same session as the same composer', async () => {
|
||||
const opened = vi.fn();
|
||||
const closed = vi.fn();
|
||||
emailHooks.onComposerOpen.register('test', opened);
|
||||
emailHooks.onComposerClose.register('test', closed);
|
||||
|
||||
const { rerender } = render(<div key="a"><EmailComposer sessionKey="same" initialData={DRAFT} /></div>);
|
||||
// Moved to another parent: React mounts a new composer.
|
||||
rerender(<section key="b"><EmailComposer sessionKey="same" initialData={DRAFT} /></section>);
|
||||
await flushCloseTimer();
|
||||
|
||||
expect(opened).toHaveBeenCalledTimes(1);
|
||||
expect(closed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports a close without a send', async () => {
|
||||
const closed = vi.fn();
|
||||
emailHooks.onComposerClose.register('test', closed);
|
||||
render(<Host />);
|
||||
|
||||
// Nothing typed: closes without asking.
|
||||
act(() => requestCloseRef.current!());
|
||||
await screen.findByText('closed');
|
||||
await flushCloseTimer();
|
||||
|
||||
expect(closed).toHaveBeenCalledTimes(1);
|
||||
expect(closed).toHaveBeenCalledWith({ draftId: null, sent: false });
|
||||
});
|
||||
|
||||
it('reports a close after a send as sent, even though the host closes mid-send', async () => {
|
||||
const closed = vi.fn();
|
||||
emailHooks.onComposerClose.register('test', closed);
|
||||
render(<Host onSend={() => new Promise((r) => setTimeout(r, 20))} />);
|
||||
|
||||
fireEvent.click(screen.getAllByTestId('composer-send')[0]);
|
||||
await screen.findByText('closed');
|
||||
await act(() => new Promise((r) => setTimeout(r, 50)));
|
||||
|
||||
expect(closed).toHaveBeenCalledTimes(1);
|
||||
expect(closed).toHaveBeenCalledWith(expect.objectContaining({ sent: true }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('onDraftAutoSave', () => {
|
||||
it('fires after the server took the draft, not when the save fails', async () => {
|
||||
const saved = vi.fn();
|
||||
emailHooks.onDraftAutoSave.register('test', saved);
|
||||
const createDraft = vi.fn().mockRejectedValueOnce(new Error('refused')).mockResolvedValue('draft-7');
|
||||
useAuthStore.setState({
|
||||
client: { createDraft, getEmail: vi.fn(), hasDelayedSend: () => false, getMaxDelayedSend: () => 0 } as never,
|
||||
});
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
try {
|
||||
render(<EmailComposer initialData={DRAFT} />);
|
||||
fireEvent.change(screen.getByTestId('composer-subject'), { target: { value: 'Lunch?' } });
|
||||
await waitFor(() => expect(createDraft).toHaveBeenCalledTimes(1), { timeout: 4000 });
|
||||
await act(async () => {});
|
||||
expect(saved).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.change(screen.getByTestId('composer-subject'), { target: { value: 'Lunch on Friday?' } });
|
||||
await waitFor(() => expect(saved).toHaveBeenCalledTimes(1), { timeout: 4000 });
|
||||
expect(saved).toHaveBeenCalledWith({ draftId: 'draft-7', subject: 'Lunch on Friday?' });
|
||||
} finally {
|
||||
consoleError.mockRestore();
|
||||
useAuthStore.setState({ client: null });
|
||||
}
|
||||
}, 15_000);
|
||||
});
|
||||
|
||||
describe('onBeforeCompose prefill', () => {
|
||||
it('fills the fields of a new message', () => {
|
||||
render(<EmailComposer prefill={{ to: ['carol@example.com'], subject: 'From a plugin' }} />);
|
||||
expect(screen.getByTestId('composer-subject')).toHaveValue('From a plugin');
|
||||
expect(screen.getByText('carol@example.com')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('goes over what the host handed in', () => {
|
||||
render(<EmailComposer initialData={DRAFT} prefill={{ subject: 'Changed' }} />);
|
||||
expect(screen.getByTestId('composer-subject')).toHaveValue('Changed');
|
||||
// Fields the plugin left alone stay.
|
||||
expect(screen.getByText('bob@example.com')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
import { render, screen, waitFor, act } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ThreadListItem } from '../thread-list-item';
|
||||
import { clearRowBadgeCache, normalizeBadges, MAX_ROW_BADGES } from '../plugin-row-badges';
|
||||
import { useSettingsStore, DEFAULT_KEYWORDS } from '@/stores/settings-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { useUIStore } from '@/stores/ui-store';
|
||||
import { groupEmailsByThread } from '@/lib/thread-utils';
|
||||
import { renderHooks, clearAllHooks } from '@/lib/plugin-hooks';
|
||||
import type { Email } from '@/lib/jmap/types';
|
||||
|
||||
// onEmailListItemRender: plugins add small badges next to a row's subject.
|
||||
|
||||
vi.mock('@/hooks/use-email-drag', () => ({
|
||||
useEmailDrag: () => ({ dragHandlers: {}, isDragging: false }),
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/auth-store', () => {
|
||||
const state = { identities: [], username: null };
|
||||
return {
|
||||
useAuthStore: (selector?: (s: typeof state) => unknown) => (selector ? selector(state) : state),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@/stores/account-store', () => {
|
||||
const state = { getAccountById: () => undefined };
|
||||
return {
|
||||
useAccountStore: Object.assign(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(selector: any) => (selector ? selector(state) : state),
|
||||
{ getState: () => state },
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
const makeEmail = (overrides: Partial<Email> = {}): Email => ({
|
||||
id: 'email-1',
|
||||
threadId: 'thread-1',
|
||||
mailboxIds: { inbox: true },
|
||||
keywords: { $seen: true },
|
||||
size: 1000,
|
||||
receivedAt: '2024-01-15T10:00:00Z',
|
||||
from: [{ name: 'Alice', email: 'alice@example.com' }],
|
||||
subject: 'Test Subject',
|
||||
hasAttachment: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
function row(emails: Email[]) {
|
||||
const [thread] = groupEmailsByThread(emails);
|
||||
return (
|
||||
<ThreadListItem thread={thread} isExpanded={false} onToggleExpand={() => {}} onEmailSelect={() => {}} />
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
clearAllHooks();
|
||||
clearRowBadgeCache();
|
||||
useUIStore.setState({ isMobile: false, isTablet: false, isDesktop: true });
|
||||
useSettingsStore.setState({
|
||||
emailKeywords: [...DEFAULT_KEYWORDS],
|
||||
showPreview: false,
|
||||
mailLayout: 'split',
|
||||
interfaceLayout: 'default',
|
||||
});
|
||||
useEmailStore.setState({ selectedEmailKeys: new Set<string>(), selectedMailbox: 'inbox' });
|
||||
});
|
||||
|
||||
describe('plugin badges in message-list rows', () => {
|
||||
it('shows the badges a plugin returns, with the email as a plain summary', async () => {
|
||||
const handler = vi.fn(async (badges: unknown[]) => [
|
||||
...badges,
|
||||
{ key: 'enc', label: 'Encrypted', color: '#2563eb', title: 'Signed and encrypted' },
|
||||
]);
|
||||
renderHooks.onEmailListItemRender.register('test', handler);
|
||||
|
||||
render(row([makeEmail()]));
|
||||
|
||||
const badge = await screen.findByText('Encrypted');
|
||||
expect(badge).toHaveAttribute('title', 'Signed and encrypted');
|
||||
expect(badge.style.borderColor).not.toBe('');
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
const [initial, ctx] = handler.mock.calls[0] as unknown as [unknown, { email: Record<string, unknown> }];
|
||||
expect(initial).toEqual([]);
|
||||
expect(ctx.email).toMatchObject({ id: 'email-1', subject: 'Test Subject', keywords: ['$seen'] });
|
||||
// A projection, not the store object.
|
||||
expect(ctx.email).not.toHaveProperty('mailboxIds.inbox');
|
||||
});
|
||||
|
||||
it('shows badges on a conversation row and on the phone layout', async () => {
|
||||
useUIStore.setState({ isMobile: true, isTablet: false, isDesktop: false });
|
||||
renderHooks.onEmailListItemRender.register('test', async () => [{ key: 'k', label: 'Phone badge' }]);
|
||||
|
||||
render(row([
|
||||
makeEmail({ id: 'a', receivedAt: '2024-01-15T10:00:00Z' }),
|
||||
makeEmail({ id: 'b', receivedAt: '2024-01-16T10:00:00Z' }),
|
||||
]));
|
||||
|
||||
expect(await screen.findByText('Phone badge')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the label as text, never as HTML', async () => {
|
||||
renderHooks.onEmailListItemRender.register('test', async () => [
|
||||
{ key: 'x', label: '<img src=x onerror=alert(1)>' },
|
||||
]);
|
||||
|
||||
const { container } = render(row([makeEmail()]));
|
||||
|
||||
expect(await screen.findByText('<img src=x onerror=alert(1)>')).toBeInTheDocument();
|
||||
expect(container.querySelector('img[src="x"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('asks plugins once per message and keyword state, not on every render', async () => {
|
||||
const handler = vi.fn(async () => [{ key: 'k', label: 'Badge' }]);
|
||||
renderHooks.onEmailListItemRender.register('test', handler);
|
||||
const email = makeEmail();
|
||||
|
||||
const view = render(row([email]));
|
||||
await screen.findByText('Badge');
|
||||
view.rerender(row([{ ...email }]));
|
||||
view.unmount();
|
||||
render(row([{ ...email }]));
|
||||
await screen.findByText('Badge');
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Marking it unread is a new keyword state: ask again.
|
||||
render(row([{ ...email, keywords: {} }]));
|
||||
await waitFor(() => expect(handler).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it('does nothing without a handler', async () => {
|
||||
const transform = vi.spyOn(renderHooks.onEmailListItemRender, 'transform');
|
||||
render(row([makeEmail()]));
|
||||
await act(async () => {});
|
||||
expect(transform).not.toHaveBeenCalled();
|
||||
expect(screen.queryByTestId('plugin-row-badges')).toBeNull();
|
||||
transform.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeBadges', () => {
|
||||
it(`keeps at most ${MAX_ROW_BADGES} well-formed badges and drops unsafe colours`, () => {
|
||||
const out = normalizeBadges([
|
||||
{ key: 'a', label: 'A', color: 'url(https://evil.example/x.png)' },
|
||||
{ key: 'b', label: '' },
|
||||
'nonsense',
|
||||
{ key: 'c', label: 'C', color: 'var(--color-warning)' },
|
||||
{ key: 'd', label: 'D', color: 'red; background: url(x)' },
|
||||
{ key: 'e', label: 'E' },
|
||||
{ key: 'f', label: 'F' },
|
||||
]);
|
||||
expect(out).toEqual([
|
||||
{ key: 'a', label: 'A' },
|
||||
{ key: 'c', label: 'C', color: 'var(--color-warning)' },
|
||||
{ key: 'd', label: 'D' },
|
||||
]);
|
||||
expect(normalizeBadges(undefined)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { render, screen, fireEvent, act, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { Email, Mailbox } from '@/lib/jmap/types';
|
||||
import { emailHooks, clearAllHooks } from '@/lib/plugin-hooks';
|
||||
|
||||
// mail-read hooks fired by the reading pane: onEmailContentRender once the
|
||||
// body is on screen and onThreadExpand for the conversation cards. (The link
|
||||
// hook runs inside the body iframe, which jsdom doesn't load; its decision is
|
||||
// covered in lib/__tests__/plugin-hook-result.test.ts.)
|
||||
|
||||
vi.mock('@/hooks/use-media-query', () => ({
|
||||
useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }),
|
||||
useMediaQuery: () => false,
|
||||
}));
|
||||
vi.mock('@/components/tour/tour-provider', () => ({
|
||||
useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }),
|
||||
}));
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||
vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] }));
|
||||
|
||||
class NoopResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
vi.stubGlobal('ResizeObserver', NoopResizeObserver);
|
||||
|
||||
const { EmailViewer } = await import('../email-viewer');
|
||||
const { ThreadConversationView } = await import('../thread-conversation-view');
|
||||
|
||||
const mailboxes = [
|
||||
{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } },
|
||||
] as unknown as Mailbox[];
|
||||
|
||||
const textEmail = (id = 'e1') => ({
|
||||
id, blobId: 'b1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true },
|
||||
from: [{ name: 'Alice', email: 'alice@example.com' }], to: [{ name: 'Bob', email: 'bob@example.com' }],
|
||||
subject: 'Hello', receivedAt: '2026-01-01T00:00:00Z', preview: 'Hello', hasAttachment: false, size: 100,
|
||||
textBody: [{ partId: '1', type: 'text/plain' }], htmlBody: [{ partId: '1', type: 'text/plain' }], attachments: [],
|
||||
bodyValues: { '1': { value: 'Hello there' } },
|
||||
}) as unknown as Email;
|
||||
|
||||
const viewer = (email: Email, extra: Record<string, unknown> = {}) => (
|
||||
<EmailViewer email={email} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" {...extra} />
|
||||
);
|
||||
|
||||
beforeEach(() => clearAllHooks());
|
||||
|
||||
describe('onEmailContentRender', () => {
|
||||
it('fires once the body is on screen, once per message', async () => {
|
||||
const handler = vi.fn();
|
||||
emailHooks.onEmailContentRender.register('test', handler);
|
||||
|
||||
const { rerender } = render(viewer(textEmail(), { isLoading: true }));
|
||||
await act(async () => {});
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
|
||||
rerender(viewer(textEmail(), { isLoading: false }));
|
||||
await waitFor(() => expect(handler).toHaveBeenCalledTimes(1));
|
||||
expect(handler).toHaveBeenCalledWith({ emailId: 'e1', hasHtml: false, hasExternalContent: false });
|
||||
|
||||
rerender(viewer(textEmail(), { isLoading: false, onReply: () => {} }));
|
||||
await act(async () => {});
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender(viewer(textEmail('e2'), { isLoading: false }));
|
||||
await waitFor(() => expect(handler).toHaveBeenCalledTimes(2));
|
||||
expect(handler).toHaveBeenLastCalledWith({ emailId: 'e2', hasHtml: false, hasExternalContent: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('onThreadExpand in the conversation view', () => {
|
||||
it('reports a card being unfolded and folded', () => {
|
||||
const handler = vi.fn();
|
||||
emailHooks.onThreadExpand.register('test', handler);
|
||||
const older = { ...textEmail('old'), keywords: { $seen: true }, receivedAt: '2025-12-01T00:00:00Z' } as Email;
|
||||
const newer = textEmail('new');
|
||||
|
||||
render(
|
||||
<ThreadConversationView
|
||||
thread={{ threadId: 't1', emails: [newer, older], latestEmail: newer } as never}
|
||||
emails={[newer, older]}
|
||||
onBack={() => {}}
|
||||
/>,
|
||||
);
|
||||
// The newest card starts unfolded, the older read one folded.
|
||||
const olderHeader = screen.getAllByText('Alice').at(-1)!;
|
||||
fireEvent.click(olderHeader);
|
||||
expect(handler).toHaveBeenLastCalledWith({ threadId: 't1', emailIds: ['old'], expanded: true });
|
||||
fireEvent.click(screen.getAllByText('Alice').at(-1)!);
|
||||
expect(handler).toHaveBeenLastCalledWith({ threadId: 't1', emailIds: ['old'], expanded: false });
|
||||
expect(handler).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -99,7 +99,10 @@ import {
|
||||
type Recipient,
|
||||
enrichChipsWithColorsAndIcons,
|
||||
ICON_MAP,
|
||||
plainTextToComposerBody,
|
||||
} from "@/lib/email-composer-utils";
|
||||
import type { ComposePrefill } from "@/lib/plugin-compose-gate";
|
||||
import { composerContextMode, composerMounted, composerUnmounted, draftSaved } from "@/lib/plugin-composer-hooks";
|
||||
import { isValidEmail } from "@/lib/validation";
|
||||
import { buildMentionCandidates } from "@/lib/recipient-mentions";
|
||||
import { RichTextEditor } from "@/components/email/rich-text-editor";
|
||||
@@ -236,6 +239,20 @@ interface EmailComposerProps {
|
||||
className?: string;
|
||||
initialDraftText?: string;
|
||||
initialData?: ComposerDraftData | null;
|
||||
/**
|
||||
* Fields a plugin filled in through onBeforeCompose, over what the composer
|
||||
* would start with. The host passes it for the first mount of a session
|
||||
* only: a remount restores the user's own text from `initialData`.
|
||||
*/
|
||||
prefill?: ComposePrefill | null;
|
||||
/**
|
||||
* Identifies the compose session across remounts (a pop-out, a layout
|
||||
* switch), so plugins hear onComposerOpen / onComposerClose once per
|
||||
* message rather than once per mount.
|
||||
*/
|
||||
sessionKey?: string;
|
||||
/** Id of the message being replied to or forwarded, for onComposerOpen. */
|
||||
sourceEmailId?: string;
|
||||
mode?: 'compose' | 'reply' | 'replyAll' | 'forward';
|
||||
/**
|
||||
* Email of the mailbox/account the user is viewing when they start a new
|
||||
@@ -348,8 +365,11 @@ export function EmailComposer({
|
||||
onDiscardDraft,
|
||||
onSaveState,
|
||||
className,
|
||||
initialDraftText,
|
||||
initialData,
|
||||
initialDraftText: initialDraftTextProp,
|
||||
initialData: initialDataProp,
|
||||
prefill,
|
||||
sessionKey,
|
||||
sourceEmailId,
|
||||
mode = 'compose',
|
||||
composeFromAccountEmail,
|
||||
replyTo
|
||||
@@ -363,8 +383,27 @@ export function EmailComposer({
|
||||
// or the format a re-opened / stashed draft was written in - and switchable
|
||||
// from the toolbar for just this message.
|
||||
const [plainTextMode, setPlainTextMode] = useState<boolean>(
|
||||
() => initialData?.plainTextMode ?? plainTextModeDefault
|
||||
() => initialDataProp?.plainTextMode ?? plainTextModeDefault
|
||||
);
|
||||
// A plugin's onBeforeCompose prefill (plain text body) folded into what the
|
||||
// composer starts from: over `initialData` when the host hands one in (a
|
||||
// mailto:, a re-opened draft), otherwise over the fields built below.
|
||||
const [initialPlainTextMode] = useState(plainTextMode);
|
||||
const initialData = useMemo<ComposerDraftData | null | undefined>(() => {
|
||||
if (!initialDataProp || !prefill) return initialDataProp;
|
||||
const next = { ...initialDataProp };
|
||||
if (prefill.to) next.to = prefill.to.join(', ');
|
||||
if (prefill.cc) {
|
||||
next.cc = prefill.cc.join(', ');
|
||||
if (prefill.cc.length > 0) next.showCc = true;
|
||||
}
|
||||
if (prefill.subject !== undefined) next.subject = prefill.subject;
|
||||
if (prefill.body !== undefined) {
|
||||
next.body = initialPlainTextMode ? prefill.body : plainTextToComposerBody(prefill.body);
|
||||
}
|
||||
return next;
|
||||
}, [initialDataProp, prefill, initialPlainTextMode]);
|
||||
const initialDraftText = !initialDataProp && prefill?.body !== undefined ? prefill.body : initialDraftTextProp;
|
||||
const subAddressDelimiter = useSettingsStore((state) => state.subAddressDelimiter);
|
||||
const autoSelectReplyIdentity = useSettingsStore((state) => state.autoSelectReplyIdentity);
|
||||
const replyIdentityMatch = useSettingsStore((state) => state.replyIdentityMatch);
|
||||
@@ -643,17 +682,22 @@ export function EmailComposer({
|
||||
// is typing lives in a separate `*Input` string per field. This keeps a
|
||||
// display name containing a comma (e.g. "Doo, John") intact instead of
|
||||
// tearing it apart on a delimiter.
|
||||
const [to, setTo] = useState<Recipient[]>(initialData ? parseRecipientList(initialData.to) : getInitialTo());
|
||||
const [cc, setCc] = useState<Recipient[]>(initialData ? parseRecipientList(initialData.cc) : getInitialCc());
|
||||
const [to, setTo] = useState<Recipient[]>(() => initialData
|
||||
? parseRecipientList(initialData.to)
|
||||
: prefill?.to ? parseRecipientList(prefill.to.join(', ')) : getInitialTo());
|
||||
const [cc, setCc] = useState<Recipient[]>(() => initialData
|
||||
? parseRecipientList(initialData.cc)
|
||||
: prefill?.cc ? parseRecipientList(prefill.cc.join(', ')) : getInitialCc());
|
||||
const [bcc, setBcc] = useState<Recipient[]>(initialData ? parseRecipientList(initialData.bcc) : []);
|
||||
const [toInput, setToInput] = useState('');
|
||||
const [ccInput, setCcInput] = useState('');
|
||||
const [bccInput, setBccInput] = useState('');
|
||||
const [subject, setSubject] = useState(initialData?.subject ?? getInitialSubject());
|
||||
const [subject, setSubject] = useState(() => initialData?.subject ?? prefill?.subject ?? getInitialSubject());
|
||||
const [body, setBody] = useState(() =>
|
||||
initialData?.body != null ? withSignatureEmbedded(initialData.body) : getInitialBody()
|
||||
);
|
||||
const [showCc, setShowCc] = useState(initialData?.showCc ?? getInitialCc().length > 0);
|
||||
const [showCc, setShowCc] = useState(() => initialData?.showCc
|
||||
?? (prefill?.cc ? prefill.cc.length > 0 : getInitialCc().length > 0));
|
||||
const [showBcc, setShowBcc] = useState(initialData?.showBcc ?? false);
|
||||
// Committed recipients plus any not-yet-committed text the user has typed.
|
||||
// Send/validation/draft paths treat a typed-but-uncommitted address as a
|
||||
@@ -1299,6 +1343,31 @@ export function EmailComposer({
|
||||
// open the next composer with the discarded reply's mode/replyTo.
|
||||
const explicitCloseRef = useRef(false);
|
||||
|
||||
// onComposerOpen / onComposerClose, once per session (see
|
||||
// lib/plugin-composer-hooks.ts). Without a key from the host every mount is
|
||||
// its own session.
|
||||
const fallbackSessionKeyRef = useRef<string | null>(null);
|
||||
if (!sessionKey && fallbackSessionKeyRef.current === null) {
|
||||
fallbackSessionKeyRef.current = `composer-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
const effectiveSessionKey = sessionKey ?? fallbackSessionKeyRef.current ?? 'composer';
|
||||
// Whether this session ended in a send, and the draft it ended with.
|
||||
const sentRef = useRef(false);
|
||||
const lastDraftIdRef = useRef<string | null>(initialData?.draftId ?? null);
|
||||
useEffect(() => {
|
||||
composerMounted(effectiveSessionKey, {
|
||||
mode: composerContextMode(mode),
|
||||
...(sourceEmailId && mode !== 'compose' ? { inReplyToId: sourceEmailId } : {}),
|
||||
...(replyTo?.subject && mode !== 'compose' ? { originalSubject: replyTo.subject } : {}),
|
||||
});
|
||||
return () => composerUnmounted(effectiveSessionKey, () => ({
|
||||
draftId: draftIdRef.current ?? lastDraftIdRef.current,
|
||||
sent: sentRef.current,
|
||||
}));
|
||||
// The session is fixed for the life of this mount.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [effectiveSessionKey]);
|
||||
|
||||
// Auto-save state on unmount (when user navigates away without explicitly closing)
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -2228,6 +2297,8 @@ export function EmailComposer({
|
||||
|
||||
setSaveStatus('saved');
|
||||
saveFailedRef.current = false;
|
||||
lastDraftIdRef.current = savedDraftId;
|
||||
draftSaved(savedDraftId, savedDraft.subject);
|
||||
|
||||
// Reset status after 2 seconds
|
||||
setTimeout(() => setSaveStatus('idle'), 2000);
|
||||
@@ -2705,6 +2776,7 @@ export function EmailComposer({
|
||||
}
|
||||
const sendHandledByPlugin = pluginOutcome === 'handled';
|
||||
if (sendHandledByPlugin) {
|
||||
sentRef.current = true;
|
||||
if (finalDraftId) {
|
||||
// The draft was created through the identity's owning account
|
||||
// (see saveDraft), so clean it up there too - not on the active
|
||||
@@ -2761,6 +2833,9 @@ export function EmailComposer({
|
||||
// draft ("Continue draft" flashed after every send). Reset below if
|
||||
// the send fails and the composer stays open.
|
||||
explicitCloseRef.current = true;
|
||||
// Read when the close is reported: the host tears the composer down
|
||||
// while onSend is still running. Reset below if the send fails.
|
||||
sentRef.current = true;
|
||||
await onSend?.({
|
||||
to: outgoing.to,
|
||||
cc: outgoing.cc,
|
||||
@@ -2819,6 +2894,7 @@ export function EmailComposer({
|
||||
} catch (err) {
|
||||
// Not sent, so the composer stays open and is an unsent draft again.
|
||||
explicitCloseRef.current = false;
|
||||
sentRef.current = false;
|
||||
debug.error('Failed to send email:', err);
|
||||
if (err instanceof RecipientsRejectedError) {
|
||||
// Nothing went out; the message stays open here so the addresses
|
||||
|
||||
@@ -31,6 +31,7 @@ import { TagDisplayContext, useMeasuredTagDisplay } from "@/hooks/use-tag-displa
|
||||
import { SearchChips } from "@/components/search/search-chips";
|
||||
import { isFilterEmpty, DEFAULT_SEARCH_FILTERS } from "@/lib/jmap/search-utils";
|
||||
import { getOwnAddresses } from "@/lib/filters/quick-rule-target";
|
||||
import { emitThreadExpand } from "@/lib/plugin-thread-expand";
|
||||
import { normalizeAddress } from "@/lib/filters/quick-rules";
|
||||
|
||||
interface EmailListProps {
|
||||
@@ -497,18 +498,34 @@ export function EmailList({
|
||||
}
|
||||
}, [client, hasMoreEmails, isLoadingMore, isLoading, isScheduledView, loadMoreEmails, onLoadMoreScheduled]);
|
||||
|
||||
// The messages of a thread as the list knows them, for onThreadExpand.
|
||||
const threadMembers = useCallback((threadKey: string): Email[] => (
|
||||
useEmailStore.getState().threadEmailsCache.get(threadKey)
|
||||
?? threadGroups.find((g) => g.threadKey === threadKey)?.emails
|
||||
?? []
|
||||
), [threadGroups]);
|
||||
|
||||
const handleToggleThreadExpansion = useCallback(async (threadKey: string) => {
|
||||
const isExpanded = expandedThreadIds.has(threadKey);
|
||||
|
||||
if (!isExpanded && client) {
|
||||
toggleThreadExpansion(threadKey);
|
||||
await fetchThreadEmails(client, threadKey);
|
||||
emitThreadExpand(threadKey, threadMembers(threadKey), true);
|
||||
// Mark all unread emails in this thread as read
|
||||
void markThreadAsRead(client, threadKey);
|
||||
} else {
|
||||
toggleThreadExpansion(threadKey);
|
||||
emitThreadExpand(threadKey, threadMembers(threadKey), !isExpanded);
|
||||
}
|
||||
}, [client, expandedThreadIds, toggleThreadExpansion, fetchThreadEmails, markThreadAsRead]);
|
||||
}, [client, expandedThreadIds, toggleThreadExpansion, fetchThreadEmails, markThreadAsRead, threadMembers]);
|
||||
|
||||
const collapseAllThreadsAndNotify = useCallback(() => {
|
||||
const wereExpanded = [...useEmailStore.getState().expandedThreadIds];
|
||||
const members = wereExpanded.map((key) => [key, threadMembers(key)] as const);
|
||||
collapseAllThreads();
|
||||
for (const [key, emails] of members) emitThreadExpand(key, emails, false);
|
||||
}, [collapseAllThreads, threadMembers]);
|
||||
|
||||
// Range-based load more: trigger when last visible item is near the end.
|
||||
// Debounce to prevent rapid cascade when thread grouping reduces item
|
||||
@@ -756,14 +773,14 @@ export function EmailList({
|
||||
isLoading={isLoadingThread === thread.threadKey}
|
||||
expandedEmails={threadEmailsCache.get(thread.threadKey)}
|
||||
onToggleExpand={() => handleToggleThreadExpansion(thread.threadKey)}
|
||||
onCollapseAllThreads={collapseAllThreads}
|
||||
onCollapseAllThreads={collapseAllThreadsAndNotify}
|
||||
onEmailSelect={(email) => {
|
||||
// Collapse expanded threads when selecting an email outside the expanded thread
|
||||
const currentExpanded = useEmailStore.getState().expandedThreadIds;
|
||||
if (currentExpanded.size > 0) {
|
||||
// Check if the selected email belongs to any expanded thread via its threadId
|
||||
if (!email.threadId || !currentExpanded.has(threadKeyFor(email))) {
|
||||
collapseAllThreads();
|
||||
collapseAllThreadsAndNotify();
|
||||
}
|
||||
}
|
||||
onEmailSelect?.(email);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Fragment, useState, useEffect, useLayoutEffect, useMemo, useRef, useCal
|
||||
import { Email, ContactCard, Mailbox } from "@/lib/jmap/types";
|
||||
import { emailExportFilename, attachmentDownloadFilename, attachmentsBundleFilename, DEFAULT_EMAIL_TEMPLATE, DEFAULT_ATTACHMENT_TEMPLATE } from "@/lib/download-filename";
|
||||
import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import";
|
||||
import { applyNewTabToAnchor, emailIframeCsp, escapeHtml, isOpenableLinkHref, plainTextToSafeHtml, sanitizeEmailBodyForIframe, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
|
||||
import { applyNewTabToAnchor, emailIframeCsp, escapeHtml, isExternalResourceUrl, isOpenableLinkHref, plainTextToSafeHtml, sanitizeEmailBodyForIframe, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
|
||||
import { getRenderableHtmlBody } from "@/lib/email-body-selection";
|
||||
import { collectReferencedCids, isEmbeddedInBody } from "@/lib/attachment-visibility";
|
||||
import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collapse";
|
||||
@@ -26,7 +26,7 @@ import { useKeywordFormat } from "@/hooks/use-keyword-format";
|
||||
import { getEmailTagIds } from "@/lib/thread-utils";
|
||||
import { getSecurityStatus, extractListHeaders, getSenderVerification } from "@/lib/email-headers";
|
||||
import { emailToReadView } from "@/lib/plugin-projection";
|
||||
import type { EmailReadView } from "@/lib/plugin-types";
|
||||
import type { EmailReadView, ExternalLinkContext } from "@/lib/plugin-types";
|
||||
import { generateEmailSource } from "@/lib/email-source";
|
||||
import {
|
||||
Reply,
|
||||
@@ -112,6 +112,7 @@ import { useMenuNavigation } from "@/hooks/use-menu-navigation";
|
||||
import { findCalendarAttachment, isCalendarMimeType } from "@/lib/calendar-invitation";
|
||||
import { RecipientPopover } from "./recipient-popover";
|
||||
import { MailtoLink } from "@/components/ui/mailto-link";
|
||||
import { requestInternalMailto } from "@/lib/protocol-handlers/mailto";
|
||||
import { imageBlobUrl, inertBlobType, isFilePreviewable, isMimeTypeSafeForInlinePreview, toInertBlob } from "@/lib/file-preview";
|
||||
import { useWopiStatus, canWopiOpen } from "@/hooks/use-wopi-status";
|
||||
import { parseTnef, isTnefAttachment } from "@/lib/tnef";
|
||||
@@ -124,7 +125,9 @@ import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { RulesPanel, useRulesAvailability } from "./rules-menu";
|
||||
import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers";
|
||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||
import { emailHooks, uiHooks, renderHooks } from "@/lib/plugin-hooks";
|
||||
import { emailHooks, renderHooks } from "@/lib/plugin-hooks";
|
||||
import { emitThreadExpand } from "@/lib/plugin-thread-expand";
|
||||
import { resolveExternalLink } from "@/lib/plugin-hook-result";
|
||||
import type { AttachmentInfo, AttachmentPreview } from "@/lib/plugin-types";
|
||||
import { useAttachmentDrag, isDragOutSupported, type AttachmentDragSource } from "@/hooks/use-attachment-drag";
|
||||
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||
@@ -1123,6 +1126,8 @@ export function EmailViewer({
|
||||
// conversation opens with its unread messages unfolded, as Gmail does, so
|
||||
// reading the thread reads all of them.
|
||||
const [mvExpandedIds, setMvExpandedIds] = useState<ReadonlySet<string>>(() => new Set());
|
||||
const mvExpandedIdsRef = useRef(mvExpandedIds);
|
||||
mvExpandedIdsRef.current = mvExpandedIds;
|
||||
const mvExpandedThreadRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!mvConversation || mvConversation.length === 0 || !email) {
|
||||
@@ -1135,6 +1140,7 @@ export function EmailViewer({
|
||||
setMvExpandedIds(new Set(mvConversation.filter((e) => e.id !== email.id && !e.keywords?.$seen).map((e) => e.id)));
|
||||
}, [mvConversation, email]);
|
||||
const toggleMvExpanded = useCallback((target: Email) => {
|
||||
emitThreadExpand(target.threadId, [target], !mvExpandedIdsRef.current.has(target.id));
|
||||
setMvExpandedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(target.id)) next.delete(target.id);
|
||||
@@ -1146,6 +1152,12 @@ export function EmailViewer({
|
||||
const [mvOpenCollapsed, setMvOpenCollapsed] = useState(false);
|
||||
useEffect(() => setMvOpenCollapsed(false), [email?.id]);
|
||||
const mvOpenFolded = mvOpenIndex >= 0 && mvOpenCollapsed;
|
||||
// Folding or unfolding the open message of a conversation, told to plugins
|
||||
// like any other conversation message (onThreadExpand).
|
||||
const setMvOpenFolded = (folded: boolean) => {
|
||||
if (email) emitThreadExpand(email.threadId, [email], !folded);
|
||||
setMvOpenCollapsed(folded);
|
||||
};
|
||||
// The conversation is not here yet (the one on hand is another thread's, or
|
||||
// none). Hold the message back instead of drawing it alone and then pushing
|
||||
// it down under the earlier messages.
|
||||
@@ -2789,6 +2801,30 @@ export function EmailViewer({
|
||||
staleDocRef.current = iframeRef.current?.contentDocument ?? null;
|
||||
}, [emailIframeSrcDoc]);
|
||||
|
||||
// onEmailContentRender: once per opened message, after its body is on
|
||||
// screen (the iframe has loaded an HTML body; a text body renders at once).
|
||||
const contentRenderedForRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!email || isBodyLoading) return;
|
||||
if (effectiveEmailContent.isHtml && !iframeReady) return;
|
||||
if (contentRenderedForRef.current === email.id) return;
|
||||
contentRenderedForRef.current = email.id;
|
||||
if (emailHooks.onEmailContentRender.size === 0) return;
|
||||
let hasExternalContent = hasBlockedContent;
|
||||
if (!hasExternalContent && effectiveEmailContent.isHtml) {
|
||||
// Loaded rather than blocked (allowed by the user or the policy): look
|
||||
// at what the rendered body actually references.
|
||||
const doc = iframeRef.current?.contentDocument;
|
||||
hasExternalContent = !!doc && Array.from(doc.querySelectorAll('[src], [srcset], [background], [poster]')).some((el) =>
|
||||
['src', 'srcset', 'background', 'poster'].some((attr) => isExternalResourceUrl(el.getAttribute(attr))));
|
||||
}
|
||||
void emailHooks.onEmailContentRender.emit({
|
||||
emailId: email.id,
|
||||
hasHtml: effectiveEmailContent.isHtml,
|
||||
hasExternalContent,
|
||||
});
|
||||
}, [email, isBodyLoading, effectiveEmailContent.isHtml, iframeReady, hasBlockedContent]);
|
||||
|
||||
// Open a blob: link the rendered body points at a cid: part. Mirrors the
|
||||
// attachment-chip gate: inert previewable types open in a tab, everything
|
||||
// else (and any blob: URL we did not mint) is downloaded under its part name.
|
||||
@@ -2914,7 +2950,17 @@ export function EmailViewer({
|
||||
const targetEl = (ev.target as Element | null)?.closest?.('a[href], area[href]') as HTMLAnchorElement | HTMLAreaElement | null;
|
||||
if (!targetEl) return;
|
||||
const href = targetEl.getAttribute('href') || '';
|
||||
if (!href || href.startsWith('#') || href.startsWith('mailto:')) return;
|
||||
if (!href || href.startsWith('#')) return;
|
||||
if (/^mailto:/i.test(href)) {
|
||||
// Write it here, like any other address in the app; the mail page
|
||||
// asks onMailtoIntercept first. Not taken (no recipient, nobody
|
||||
// listening): the browser's own handling stays.
|
||||
if (requestInternalMailto(href)) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
}
|
||||
return;
|
||||
}
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
// A link at a cid: part now carries the blob: URL we minted for it.
|
||||
@@ -2929,14 +2975,16 @@ export function EmailViewer({
|
||||
// data:, unresolved cid: and relative paths would land inside our
|
||||
// own origin.
|
||||
if (!isOpenableLinkHref(href)) return;
|
||||
const ctx = {
|
||||
const ctx: ExternalLinkContext = {
|
||||
href,
|
||||
target: targetEl.getAttribute('target') ?? undefined,
|
||||
emailId: email?.id,
|
||||
};
|
||||
const ok = await uiHooks.onBeforeExternalLink.intercept(ctx);
|
||||
if (!ok || !isOpenableLinkHref(ctx.href)) return;
|
||||
window.open(ctx.href, '_blank', 'noopener,noreferrer');
|
||||
// A plugin returns false to stop the link or { href } to send it
|
||||
// somewhere else (a web address only).
|
||||
const target = await resolveExternalLink(ctx);
|
||||
if (!target) return;
|
||||
window.open(target, '_blank', 'noopener,noreferrer');
|
||||
};
|
||||
doc.addEventListener('click', onLinkClick, true);
|
||||
|
||||
@@ -4430,7 +4478,7 @@ export function EmailViewer({
|
||||
{/* Mountain View: the open message folded like the rest of the conversation. */}
|
||||
{mvOpenFolded && email ? (
|
||||
<div data-mv-open-folded="">
|
||||
<MvConversationCard email={email} onOpen={() => setMvOpenCollapsed(false)} timeFormat={timeFormat} compact={mvMobileViewer} />
|
||||
<MvConversationCard email={email} onOpen={() => setMvOpenFolded(false)} timeFormat={timeFormat} compact={mvMobileViewer} />
|
||||
</div>
|
||||
) : mvAwaitingConversation ? (
|
||||
<div className={cn("flex items-start py-3 animate-pulse", mvMobileViewer ? "gap-3 px-4" : "gap-4 px-6")} data-mv-conversation-pending="">
|
||||
@@ -4452,7 +4500,7 @@ export function EmailViewer({
|
||||
// message, as Gmail does; its buttons and links keep their own job.
|
||||
if (isInteractiveClick(e.target, e.currentTarget)) return;
|
||||
if (window.getSelection()?.toString()) return;
|
||||
setMvOpenCollapsed(true);
|
||||
setMvOpenFolded(true);
|
||||
} : undefined}
|
||||
>
|
||||
<div className="flex items-start" style={{ gap: mvViewer ? '16px' : 'var(--density-item-gap)' }}>
|
||||
@@ -4770,7 +4818,7 @@ export function EmailViewer({
|
||||
onClick={mvOpenIndex >= 0 ? (e) => {
|
||||
if (isInteractiveClick(e.target, e.currentTarget)) return;
|
||||
if (window.getSelection()?.toString()) return;
|
||||
setMvOpenCollapsed(true);
|
||||
setMvOpenFolded(true);
|
||||
} : undefined}
|
||||
>
|
||||
<div className="flex items-start" style={{ gap: 'var(--density-item-gap)' }}>
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import type { Email } from "@/lib/jmap/types";
|
||||
import type { EmailListBadge } from "@/lib/plugin-types";
|
||||
import { renderHooks } from "@/lib/plugin-hooks";
|
||||
import { emailSummary } from "@/lib/plugin-projection";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TAG_GROUP_CLASS, TAG_LOZENGE_CLASS } from "./tag-badge";
|
||||
|
||||
// Badges plugins add to a message-list row through onEmailListItemRender.
|
||||
//
|
||||
// The hook is async and would otherwise run for every row on every render, so
|
||||
// the answer is kept per message and keyword state: scrolling a row back into
|
||||
// view, or re-rendering the list, reuses it. A keyword change (read, flagged,
|
||||
// a tag) asks again, since that is what most badges are about.
|
||||
|
||||
/** At most this many plugin badges per row; the row has little room. */
|
||||
export const MAX_ROW_BADGES = 3;
|
||||
const MAX_LABEL_LENGTH = 32;
|
||||
const MAX_TITLE_LENGTH = 200;
|
||||
const CACHE_LIMIT = 2000;
|
||||
|
||||
const cache = new Map<string, EmailListBadge[]>();
|
||||
const inflight = new Map<string, Promise<EmailListBadge[]>>();
|
||||
|
||||
function cacheKey(email: Email, listeners: number): string {
|
||||
const keywords = Object.keys(email.keywords ?? {})
|
||||
.filter((k) => email.keywords?.[k])
|
||||
.sort()
|
||||
.join(",");
|
||||
// The listener count is part of the key so a plugin being switched on or
|
||||
// off doesn't leave the old answer behind.
|
||||
return `${listeners}|${email.id}|${keywords}`;
|
||||
}
|
||||
|
||||
// A colour the badge may use: hex, rgb()/hsl() with plain numbers, a CSS
|
||||
// variable or a colour name. Anything else (url(), expressions, several
|
||||
// declarations) is dropped and the badge keeps the theme colours.
|
||||
const SAFE_COLOR = /^(#[0-9a-f]{3,8}|(rgb|rgba|hsl|hsla)\([\d\s.,%/+-]+\)|var\(--[a-z0-9-]+\)|[a-z]{3,30})$/i;
|
||||
|
||||
// eslint-disable-next-line no-control-regex -- removing control characters is the point
|
||||
const CONTROL_CHARS = /[\u0000-\u001f\u007f]/g;
|
||||
|
||||
function cleanText(value: unknown, max: number): string {
|
||||
return typeof value === "string" ? value.replace(CONTROL_CHARS, "").trim().slice(0, max) : "";
|
||||
}
|
||||
|
||||
/** Keep only well-formed badges, as plain text, capped at MAX_ROW_BADGES. */
|
||||
export function normalizeBadges(raw: unknown): EmailListBadge[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const out: EmailListBadge[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const item of raw) {
|
||||
if (out.length >= MAX_ROW_BADGES) break;
|
||||
if (!item || typeof item !== "object") continue;
|
||||
const candidate = item as Record<string, unknown>;
|
||||
const label = cleanText(candidate.label, MAX_LABEL_LENGTH);
|
||||
if (!label) continue;
|
||||
const key = cleanText(candidate.key, 100) || label;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
const badge: EmailListBadge = { key, label };
|
||||
const color = cleanText(candidate.color, 60);
|
||||
if (color && SAFE_COLOR.test(color)) badge.color = color;
|
||||
const title = cleanText(candidate.title, MAX_TITLE_LENGTH);
|
||||
if (title) badge.title = title;
|
||||
out.push(badge);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function remember(key: string, badges: EmailListBadge[]): void {
|
||||
if (cache.size >= CACHE_LIMIT) {
|
||||
const oldest = cache.keys().next().value;
|
||||
if (oldest !== undefined) cache.delete(oldest);
|
||||
}
|
||||
cache.set(key, badges);
|
||||
}
|
||||
|
||||
function resolveBadges(email: Email, key: string): Promise<EmailListBadge[]> {
|
||||
const pending = inflight.get(key);
|
||||
if (pending) return pending;
|
||||
const promise = renderHooks.onEmailListItemRender.transform<EmailListBadge[]>([], { email: emailSummary(email) })
|
||||
.then(normalizeBadges, () => [])
|
||||
.then((badges) => {
|
||||
remember(key, badges);
|
||||
return badges;
|
||||
})
|
||||
.finally(() => inflight.delete(key));
|
||||
inflight.set(key, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
/** Forget every cached answer (tests). */
|
||||
export function clearRowBadgeCache(): void {
|
||||
cache.clear();
|
||||
inflight.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* The plugin badges for a row: none at first, then whatever the plugins
|
||||
* returned. Without any onEmailListItemRender handler this does nothing.
|
||||
*/
|
||||
export function usePluginRowBadges(email: Email): EmailListBadge[] {
|
||||
const listeners = renderHooks.onEmailListItemRender.size;
|
||||
const key = listeners > 0 ? cacheKey(email, listeners) : null;
|
||||
const [state, setState] = useState<{ key: string | null; badges: EmailListBadge[] }>(() => ({
|
||||
key,
|
||||
badges: key ? cache.get(key) ?? [] : [],
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
if (!key) return;
|
||||
const cached = cache.get(key);
|
||||
if (cached) {
|
||||
setState((prev) => (prev.key === key && prev.badges === cached ? prev : { key, badges: cached }));
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void resolveBadges(email, key).then((badges) => {
|
||||
if (!cancelled) setState({ key, badges });
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
// `key` covers the parts of the email the answer depends on.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [key]);
|
||||
|
||||
if (!key) return [];
|
||||
// While a new answer is on its way, show the cached one for this key, if
|
||||
// any, rather than the previous key's badges.
|
||||
return state.key === key ? state.badges : cache.get(key) ?? [];
|
||||
}
|
||||
|
||||
/** Plugin badges next to a row's subject. Renders nothing when there are none. */
|
||||
export function PluginRowBadges({ email, className }: { email: Email; className?: string }) {
|
||||
const badges = usePluginRowBadges(email);
|
||||
if (badges.length === 0) return null;
|
||||
return (
|
||||
<span className={cn(TAG_GROUP_CLASS, className)} data-testid="plugin-row-badges">
|
||||
{badges.map((badge) => (
|
||||
<span
|
||||
key={badge.key}
|
||||
title={badge.title}
|
||||
className={cn(
|
||||
TAG_LOZENGE_CLASS,
|
||||
"max-w-[8rem] truncate border",
|
||||
!badge.color && "border-border bg-muted text-muted-foreground",
|
||||
)}
|
||||
style={badge.color ? {
|
||||
color: badge.color,
|
||||
borderColor: badge.color,
|
||||
backgroundColor: `color-mix(in srgb, ${badge.color} 12%, transparent)`,
|
||||
} : undefined}
|
||||
>
|
||||
{badge.label}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -41,6 +41,7 @@ import { useAuthStore } from "@/stores/auth-store";
|
||||
import { isFilePreviewable, toInertBlob } from "@/lib/file-preview";
|
||||
import { useWopiStatus, canWopiOpen } from "@/hooks/use-wopi-status";
|
||||
import { useOwnDomainAddress } from "@/hooks/use-own-domain-address";
|
||||
import { emitThreadExpand } from "@/lib/plugin-thread-expand";
|
||||
|
||||
interface ThreadConversationViewProps {
|
||||
thread: ThreadGroup;
|
||||
@@ -193,6 +194,8 @@ export function ThreadConversationView({
|
||||
}, [emails]);
|
||||
|
||||
const toggleExpanded = (emailId: string) => {
|
||||
const target = emails.find((e) => e.id === emailId);
|
||||
if (target) emitThreadExpand(target.threadId, [target], !expandedIds.has(emailId));
|
||||
setExpandedIds(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(emailId)) {
|
||||
|
||||
@@ -29,6 +29,7 @@ import type { SwipeAction } from "@/stores/settings-store";
|
||||
import { ThreadEmailItem } from "./thread-email-item";
|
||||
import { EmailHoverActions } from "./email-hover-actions";
|
||||
import { SearchSnippetText } from "./search-snippet-text";
|
||||
import { PluginRowBadges } from "./plugin-row-badges";
|
||||
import { useOwnDomainAddress } from "@/hooks/use-own-domain-address";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
@@ -596,6 +597,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
)}>
|
||||
{subjectSnippet ? <SearchSnippetText snippet={subjectSnippet} /> : (email.subject || '(no subject)')}
|
||||
</span>
|
||||
<PluginRowBadges email={email} className={mvRow ? "ms-2" : undefined} />
|
||||
{(previewSnippet || inlinePreview) && (
|
||||
<span className="min-w-0 shrink-[9999] truncate text-muted-foreground">
|
||||
{/* Gmail runs the snippet on from the subject after a dash. */}
|
||||
@@ -738,6 +740,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
)}>
|
||||
{subjectSnippet ? <SearchSnippetText snippet={subjectSnippet} /> : (email.subject || "(no subject)")}
|
||||
</span>
|
||||
<PluginRowBadges email={email} />
|
||||
</div>
|
||||
|
||||
{showPreview && density !== 'extra-compact' && density !== 'compact' && (
|
||||
@@ -1182,6 +1185,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
)}>
|
||||
{subjectSnippet ? <SearchSnippetText snippet={subjectSnippet} /> : (latestEmail.subject || '(no subject)')}
|
||||
</span>
|
||||
<PluginRowBadges email={latestEmail} className={mvRow ? "ms-2" : undefined} />
|
||||
{(previewSnippet || inlinePreview) && (
|
||||
<span className="min-w-0 shrink-[9999] truncate text-muted-foreground">
|
||||
{mvRow && '\u00a0-\u00a0'}
|
||||
@@ -1330,6 +1334,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
)}>
|
||||
{subjectSnippet ? <SearchSnippetText snippet={subjectSnippet} /> : (latestEmail.subject || "(no subject)")}
|
||||
</span>
|
||||
<PluginRowBadges email={latestEmail} />
|
||||
</div>
|
||||
|
||||
{showPreview && density !== 'extra-compact' && density !== 'compact' && (
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
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 { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
|
||||
import { emailHooks, clearAllHooks } from '@/lib/plugin-hooks';
|
||||
import { requestInternalMailto } from '@/lib/protocol-handlers/mailto';
|
||||
|
||||
// onBeforeCompose and onMailtoIntercept in the mail page: a plugin can stop
|
||||
// the composer from opening or prefill it, and stop a mailto: link.
|
||||
|
||||
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 composerProps = vi.hoisted(() => ({ last: null as Record<string, unknown> | null }));
|
||||
vi.mock('@/components/email/email-composer', () => ({
|
||||
EmailComposer: (props: Record<string, unknown>) => {
|
||||
composerProps.last = props;
|
||||
return <div>stub composer</div>;
|
||||
},
|
||||
}));
|
||||
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: true, isTablet: false, isDesktop: false }),
|
||||
useMediaQuery: () => false,
|
||||
useIsDesktop: () => false,
|
||||
}));
|
||||
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 sendEmail = 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) });
|
||||
const client = {
|
||||
getAccountId: () => 'acc', getUsername: () => 'me@example.com',
|
||||
getSieveAccountId: () => null, hasDelayedSend: () => false, 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: 0 } as never);
|
||||
sendEmail.mockReset().mockResolvedValue({});
|
||||
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: vi.fn().mockResolvedValue(undefined),
|
||||
sendEmail,
|
||||
} as never);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
|
||||
const openCompose = () => fireEvent.click(screen.getByRole('button', { name: 'sidebar.compose' }));
|
||||
|
||||
describe('onBeforeCompose', () => {
|
||||
beforeEach(() => {
|
||||
clearAllHooks();
|
||||
composerProps.last = null;
|
||||
});
|
||||
|
||||
it('opens the composer as before when no plugin listens', async () => {
|
||||
render(<MailApp />);
|
||||
openCompose();
|
||||
await screen.findByText('stub composer', {}, { timeout: 5000 });
|
||||
expect(composerProps.last?.prefill).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the composer closed when a plugin returns false', async () => {
|
||||
const handler = vi.fn(() => false);
|
||||
emailHooks.onBeforeCompose.register('test', handler);
|
||||
render(<MailApp />);
|
||||
openCompose();
|
||||
await waitFor(() => expect(handler).toHaveBeenCalledTimes(1));
|
||||
await act(() => new Promise((r) => setTimeout(r, 50)));
|
||||
expect(screen.queryByText('stub composer')).not.toBeInTheDocument();
|
||||
expect(handler).toHaveBeenCalledWith({ mode: 'new', to: [], cc: [], subject: '', body: '' });
|
||||
});
|
||||
|
||||
it('hands the fields a plugin returns to the composer', async () => {
|
||||
emailHooks.onBeforeCompose.register('test', (opts: Record<string, unknown>) => ({
|
||||
...opts, to: ['carol@example.com'], subject: 'Weekly report',
|
||||
}));
|
||||
render(<MailApp />);
|
||||
openCompose();
|
||||
await screen.findByText('stub composer', {}, { timeout: 5000 });
|
||||
expect(composerProps.last?.prefill).toEqual({ to: ['carol@example.com'], subject: 'Weekly report' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('onMailtoIntercept', () => {
|
||||
beforeEach(() => {
|
||||
clearAllHooks();
|
||||
composerProps.last = null;
|
||||
});
|
||||
|
||||
it('asks plugins with the link and opens the composer when they agree', async () => {
|
||||
const intercept = vi.fn(() => true);
|
||||
const before = vi.fn(() => undefined);
|
||||
emailHooks.onMailtoIntercept.register('test', intercept);
|
||||
emailHooks.onBeforeCompose.register('test', before);
|
||||
render(<MailApp />);
|
||||
|
||||
let taken = false;
|
||||
act(() => { taken = requestInternalMailto('mailto:bob@example.com?subject=Hi'); });
|
||||
expect(taken).toBe(true);
|
||||
|
||||
await screen.findByText('stub composer', {}, { timeout: 5000 });
|
||||
expect(intercept).toHaveBeenCalledWith({ href: 'mailto:bob@example.com?subject=Hi', to: ['bob@example.com'], subject: 'Hi' });
|
||||
expect(before).toHaveBeenCalledWith(expect.objectContaining({ mode: 'new', to: ['bob@example.com'], subject: 'Hi' }));
|
||||
expect(composerProps.last?.initialData).toEqual(expect.objectContaining({ to: 'bob@example.com', subject: 'Hi' }));
|
||||
});
|
||||
|
||||
it('keeps the composer closed when a plugin returns false', async () => {
|
||||
const intercept = vi.fn(() => false);
|
||||
emailHooks.onMailtoIntercept.register('test', intercept);
|
||||
render(<MailApp />);
|
||||
|
||||
let taken = false;
|
||||
act(() => { taken = requestInternalMailto('mailto:bob@example.com'); });
|
||||
// Still taken: the click must not fall through to the OS handler.
|
||||
expect(taken).toBe(true);
|
||||
await waitFor(() => expect(intercept).toHaveBeenCalledTimes(1));
|
||||
await act(() => new Promise((r) => setTimeout(r, 50)));
|
||||
expect(screen.queryByText('stub composer')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+148
-51
@@ -117,6 +117,10 @@ import { INTERNAL_MAILTO_EVENT, type ParsedMailto } from "@/lib/protocol-handler
|
||||
import { plainTextToComposerBody, getQuoteBodies } from "@/lib/email-composer-utils";
|
||||
import { 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";
|
||||
import { buildComposeTabTitle, buildReplySubject } from "@/lib/subject-prefix";
|
||||
import { buildForwardAsAttachmentPayload } from "@/lib/forward-as-attachment";
|
||||
@@ -190,6 +194,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
const [composerDraftText, setComposerDraftText] = useState("");
|
||||
const [pendingDraft, setPendingDraft] = useState<ComposerDraftData | null>(null);
|
||||
const [composerSessionId, setComposerSessionId] = useState(0);
|
||||
// Fields a plugin filled in through onBeforeCompose for the composer about
|
||||
// to open. Dropped once the composer hands its own state back, so a
|
||||
// remount restores what the user wrote instead.
|
||||
const [composerPrefill, setComposerPrefill] = useState<ComposePrefill | null>(null);
|
||||
// Plugin-resolved quote header for the next reply/forward composer open.
|
||||
// Cleared on close so a subsequent "compose new" doesn't reuse stale state.
|
||||
const [composerQuoteHeader, setComposerQuoteHeader] = useState<QuoteHeader | null>(null);
|
||||
@@ -817,10 +825,12 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
}
|
||||
},
|
||||
onCompose: () => {
|
||||
startFreshComposerSession();
|
||||
setComposerMode('compose');
|
||||
setShowComposer(true);
|
||||
if (isMobile) setActiveView('viewer');
|
||||
void withComposeGate('compose', {}, () => {
|
||||
startFreshComposerSession();
|
||||
setComposerMode('compose');
|
||||
setShowComposer(true);
|
||||
if (isMobile) setActiveView('viewer');
|
||||
});
|
||||
},
|
||||
onFocusSearch: () => {
|
||||
if (isScheduledView) return;
|
||||
@@ -891,10 +901,15 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
const store = useEmailStore.getState();
|
||||
const wasExpanded = store.expandedThreadIds.has(threadKey);
|
||||
store.toggleThreadExpansion(threadKey);
|
||||
const members = () => useEmailStore.getState().threadEmailsCache.get(threadKey)
|
||||
?? activeEmails.filter((e) => e.threadId && threadKeyFor(e) === threadKey);
|
||||
if (!wasExpanded) {
|
||||
void store.fetchThreadEmails(client, threadKey).then(() => {
|
||||
emitThreadExpand(threadKey, members(), true);
|
||||
void useEmailStore.getState().markThreadAsRead(client, threadKey);
|
||||
});
|
||||
} else {
|
||||
emitThreadExpand(threadKey, members(), false);
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -1043,6 +1058,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
replyTo,
|
||||
initialDraftText: composerDraftText,
|
||||
initialData: pendingDraft,
|
||||
prefill: composerPrefill,
|
||||
sourceEmailId: selectedEmail?.id ?? null,
|
||||
title,
|
||||
});
|
||||
@@ -1051,6 +1067,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
setShowComposer(false);
|
||||
setComposerDraftText("");
|
||||
setPendingDraft(null);
|
||||
setComposerPrefill(null);
|
||||
setComposerQuoteHeader(null);
|
||||
// We only react to the rising edge of `showComposer` here; the other
|
||||
// variables read above are captured-but-stale-safe because the next
|
||||
@@ -1101,6 +1118,31 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
}
|
||||
}, [initialCheckDone, isAuthenticated, authLoading]);
|
||||
|
||||
// onBeforeCompose, in front of every way the composer opens. `open` runs
|
||||
// once plugins agreed (straight away when none listens); a plugin's
|
||||
// prefill goes to the composer through `composerPrefill`.
|
||||
const withComposeGate = useCallback(async (
|
||||
mode: ComposerOpenMode,
|
||||
fields: Omit<NonNullable<Parameters<typeof composeOptionsFor>[1]>, 'ownEmails' | 'replyPrefix' | 'forwardPrefix'>,
|
||||
open: () => void,
|
||||
): Promise<boolean> => {
|
||||
if (emailHooks.onBeforeCompose.size === 0) {
|
||||
setComposerPrefill(null);
|
||||
open();
|
||||
return true;
|
||||
}
|
||||
const prefill = await runBeforeCompose(composeOptionsFor(mode, {
|
||||
...fields,
|
||||
ownEmails: identities.map((i) => i.email).filter((e): e is string => Boolean(e)),
|
||||
replyPrefix: t('email_composer.prefix.reply'),
|
||||
forwardPrefix: t('email_composer.prefix.forward'),
|
||||
}));
|
||||
if (!prefill) return false;
|
||||
setComposerPrefill(Object.keys(prefill).length > 0 ? prefill : null);
|
||||
open();
|
||||
return true;
|
||||
}, [identities, t]);
|
||||
|
||||
const applyMailtoDraft = useCallback((pending: ParsedMailto, suppressOutgoingStash: boolean) => {
|
||||
const body = useSettingsStore.getState().plainTextMode
|
||||
? pending.body
|
||||
@@ -1128,7 +1170,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
if (isMobile) setActiveView("viewer");
|
||||
}, [composerSessionId, isMobile, setActiveView]);
|
||||
|
||||
const openMailtoDraft = useCallback((pending: ParsedMailto) => {
|
||||
const openMailtoDraft = useCallback((pending: ParsedMailto) => withComposeGate('compose', {
|
||||
to: pending.to, cc: pending.cc, subject: pending.subject, body: pending.body,
|
||||
}, () => {
|
||||
// A live composer owns text the user has not sent. Replacing it outright
|
||||
// used to drop that text on the floor - the stash suppression below is an
|
||||
// ordering fix, not a decision anyone made. Route the request through the
|
||||
@@ -1143,7 +1187,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
return;
|
||||
}
|
||||
applyMailtoDraft(pending, showComposer);
|
||||
}, [applyMailtoDraft, showComposer]);
|
||||
}), [applyMailtoDraft, showComposer, withComposeGate]);
|
||||
|
||||
const openMailtoForAccount = useCallback(async (pending: ParsedMailto, accountId: string) => {
|
||||
setIsProtocolAccountSwitching(true);
|
||||
@@ -1158,7 +1202,19 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
}
|
||||
}, [openMailtoDraft, switchAccount]);
|
||||
|
||||
const handleMailtoProtocolRequest = useCallback((pending: ParsedMailto) => {
|
||||
// onMailtoIntercept: a plugin may stop a mailto: from opening the composer.
|
||||
const mailtoAllowed = useCallback(async (pending: ParsedMailto & { href?: string }): Promise<boolean> => {
|
||||
if (emailHooks.onMailtoIntercept.size === 0) return true;
|
||||
return emailHooks.onMailtoIntercept.intercept({
|
||||
href: pending.href ?? formatMailto(pending),
|
||||
to: [...pending.to],
|
||||
...(pending.subject ? { subject: pending.subject } : {}),
|
||||
...(pending.body ? { body: pending.body } : {}),
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleMailtoProtocolRequest = useCallback(async (pending: ParsedMailto) => {
|
||||
if (!(await mailtoAllowed(pending))) return;
|
||||
const protocolAccounts = getMailtoProtocolAccounts();
|
||||
if (protocolAccounts.length > 1) {
|
||||
setPendingMailtoAccountChoice(pending);
|
||||
@@ -1172,14 +1228,14 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
}
|
||||
|
||||
openMailtoDraft(pending);
|
||||
}, [activeAccountId, getMailtoProtocolAccounts, openMailtoDraft, openMailtoForAccount]);
|
||||
}, [activeAccountId, getMailtoProtocolAccounts, mailtoAllowed, openMailtoDraft, openMailtoForAccount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated || !client) return;
|
||||
|
||||
const openPendingMailto = () => {
|
||||
const pending = consumePendingMailto();
|
||||
if (pending) handleMailtoProtocolRequest(pending);
|
||||
if (pending) void handleMailtoProtocolRequest(pending);
|
||||
};
|
||||
|
||||
openPendingMailto();
|
||||
@@ -1195,15 +1251,19 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
if (!isAuthenticated || !client) return;
|
||||
|
||||
const onInternalMailto = (event: Event) => {
|
||||
const pending = (event as CustomEvent<ParsedMailto>).detail;
|
||||
const pending = (event as CustomEvent<ParsedMailto & { href?: string }>).detail;
|
||||
if (!pending) return;
|
||||
// Taken, whatever plugins say: the click must not fall through to the
|
||||
// OS mail handler.
|
||||
event.preventDefault();
|
||||
openMailtoDraft(pending);
|
||||
void mailtoAllowed(pending).then((allowed) => {
|
||||
if (allowed) void openMailtoDraft(pending);
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener(INTERNAL_MAILTO_EVENT, onInternalMailto);
|
||||
return () => window.removeEventListener(INTERNAL_MAILTO_EVENT, onInternalMailto);
|
||||
}, [isAuthenticated, client, openMailtoDraft]);
|
||||
}, [isAuthenticated, client, mailtoAllowed, openMailtoDraft]);
|
||||
|
||||
// Fallback fetch for paths that didn't go through login()'s prefetch
|
||||
// (notably checkAuth on page refresh). Settings pages can also prefill
|
||||
@@ -1832,6 +1892,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
|
||||
@@ -2014,11 +2075,13 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
} else {
|
||||
setComposerQuoteHeader(null);
|
||||
}
|
||||
startFreshComposerSession();
|
||||
setComposerDraftText(draftText || "");
|
||||
setComposerMode('reply');
|
||||
setShowComposer(true);
|
||||
if (isMobile) setActiveView('viewer');
|
||||
await withComposeGate('reply', { source: selectedEmail, body: draftText || '' }, () => {
|
||||
startFreshComposerSession();
|
||||
setComposerDraftText(draftText || "");
|
||||
setComposerMode('reply');
|
||||
setShowComposer(true);
|
||||
if (isMobile) setActiveView('viewer');
|
||||
});
|
||||
};
|
||||
|
||||
const handleEditDraft = async (email?: Email) => {
|
||||
@@ -2063,6 +2126,14 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
: identities;
|
||||
const matchedIdentityId = findDraftIdentityId(composerIdentities, draft.from?.[0]);
|
||||
|
||||
const draftRecipients = (list: typeof draft.to) => (list ?? []).map(a => a.email).filter(Boolean);
|
||||
if (!(await withComposeGate('compose', {
|
||||
to: draftRecipients(draft.to),
|
||||
cc: draftRecipients(draft.cc),
|
||||
subject: draft.subject && draft.subject !== t('email_composer.no_subject') ? draft.subject : '',
|
||||
body: bodyText,
|
||||
}, () => {}))) return;
|
||||
|
||||
// Increment session ID to force the composer to remount with fresh state,
|
||||
// even if it was already open (e.g. right-clicking a draft while composing).
|
||||
setComposerSessionId(id => id + 1);
|
||||
@@ -2148,6 +2219,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);
|
||||
@@ -2162,6 +2234,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);
|
||||
}
|
||||
@@ -2210,10 +2284,12 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
} else {
|
||||
setComposerQuoteHeader(null);
|
||||
}
|
||||
startFreshComposerSession();
|
||||
setComposerMode('replyAll');
|
||||
setShowComposer(true);
|
||||
if (isMobile) setActiveView('viewer');
|
||||
await withComposeGate('replyAll', { source: selectedEmail }, () => {
|
||||
startFreshComposerSession();
|
||||
setComposerMode('replyAll');
|
||||
setShowComposer(true);
|
||||
if (isMobile) setActiveView('viewer');
|
||||
});
|
||||
};
|
||||
|
||||
const handleForward = async () => {
|
||||
@@ -2234,10 +2310,12 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
} else {
|
||||
setComposerQuoteHeader(null);
|
||||
}
|
||||
startFreshComposerSession();
|
||||
setComposerMode('forward');
|
||||
setShowComposer(true);
|
||||
if (isMobile) setActiveView('viewer');
|
||||
await withComposeGate('forward', { source: selectedEmail }, () => {
|
||||
startFreshComposerSession();
|
||||
setComposerMode('forward');
|
||||
setShowComposer(true);
|
||||
if (isMobile) setActiveView('viewer');
|
||||
});
|
||||
};
|
||||
|
||||
// Forward the original message as a message/rfc822 attachment instead of
|
||||
@@ -2289,6 +2367,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
if (!(await withComposeGate('forward', { subject: payload.subject }, () => {}))) return;
|
||||
startFreshComposerSession();
|
||||
setPendingDraft({
|
||||
to: "",
|
||||
@@ -3801,30 +3880,36 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
email = await emailHooks.onBeforeComposeOpenToReply.transform(email);
|
||||
selectEmail(email);
|
||||
await prepareComposerQuoteHeader(email, 'reply');
|
||||
startFreshComposerSession();
|
||||
setComposerMode('reply');
|
||||
setShowComposer(true);
|
||||
if (isMobile) setActiveView('viewer');
|
||||
await withComposeGate('reply', { source: email }, () => {
|
||||
startFreshComposerSession();
|
||||
setComposerMode('reply');
|
||||
setShowComposer(true);
|
||||
if (isMobile) setActiveView('viewer');
|
||||
});
|
||||
};
|
||||
|
||||
const handleConversationReplyAll = async (email: Email) => {
|
||||
email = await emailHooks.onBeforeComposeOpenToReplyAll.transform(email);
|
||||
selectEmail(email);
|
||||
await prepareComposerQuoteHeader(email, 'replyAll');
|
||||
startFreshComposerSession();
|
||||
setComposerMode('replyAll');
|
||||
setShowComposer(true);
|
||||
if (isMobile) setActiveView('viewer');
|
||||
await withComposeGate('replyAll', { source: email }, () => {
|
||||
startFreshComposerSession();
|
||||
setComposerMode('replyAll');
|
||||
setShowComposer(true);
|
||||
if (isMobile) setActiveView('viewer');
|
||||
});
|
||||
};
|
||||
|
||||
const handleConversationForward = async (email: Email) => {
|
||||
email = await emailHooks.onBeforeComposeOpenToForward.transform(email);
|
||||
selectEmail(email);
|
||||
await prepareComposerQuoteHeader(email, 'forward');
|
||||
startFreshComposerSession();
|
||||
setComposerMode('forward');
|
||||
setShowComposer(true);
|
||||
if (isMobile) setActiveView('viewer');
|
||||
await withComposeGate('forward', { source: email }, () => {
|
||||
startFreshComposerSession();
|
||||
setComposerMode('forward');
|
||||
setShowComposer(true);
|
||||
if (isMobile) setActiveView('viewer');
|
||||
});
|
||||
};
|
||||
|
||||
const ToggleChip = ({ icon, label, value, onClick }: { icon: React.ReactNode; label: string; value: boolean | null; onClick: () => void }) => (
|
||||
@@ -3919,7 +4004,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
} : undefined)}
|
||||
initialDraftText={composerDraftText}
|
||||
initialData={pendingDraft}
|
||||
prefill={composerPrefill}
|
||||
sessionKey={`inline-${composerSessionId}`}
|
||||
sourceEmailId={composerSourceEmail?.id}
|
||||
onSaveState={(data) => {
|
||||
setComposerPrefill(null);
|
||||
if (suppressComposerStateSaveSessionRef.current === composerSessionId) {
|
||||
suppressComposerStateSaveSessionRef.current = null;
|
||||
return;
|
||||
@@ -3929,6 +4018,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
onSend={async (data) => {
|
||||
await handleEmailSend(data);
|
||||
setPendingDraft(null);
|
||||
setComposerPrefill(null);
|
||||
}}
|
||||
onScheduledSendCreated={async () => {
|
||||
if (client) {
|
||||
@@ -3944,6 +4034,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
setComposerMode('compose');
|
||||
setComposerDraftText("");
|
||||
setPendingDraft(null);
|
||||
setComposerPrefill(null);
|
||||
setComposerQuoteHeader(null);
|
||||
if (isMobile) {
|
||||
setActiveView('list');
|
||||
@@ -4118,13 +4209,15 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
onImportEmail={handleImportEmailFromContextMenu}
|
||||
onRefreshMailboxes={handleRefreshMailboxes}
|
||||
onCompose={() => {
|
||||
startFreshComposerSession();
|
||||
setComposerMode('compose');
|
||||
setShowComposer(true);
|
||||
if (isMobile) {
|
||||
setSidebarOpen(false);
|
||||
setActiveView('viewer');
|
||||
}
|
||||
void withComposeGate('compose', {}, () => {
|
||||
startFreshComposerSession();
|
||||
setComposerMode('compose');
|
||||
setShowComposer(true);
|
||||
if (isMobile) {
|
||||
setSidebarOpen(false);
|
||||
setActiveView('viewer');
|
||||
}
|
||||
});
|
||||
}}
|
||||
onSidebarClose={() => setSidebarOpen(false)}
|
||||
mvShell={mvShell}
|
||||
@@ -4635,10 +4728,12 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
at the top of the navigation rail instead. */}
|
||||
{!mvShell && <Button
|
||||
onClick={() => {
|
||||
startFreshComposerSession();
|
||||
setComposerMode('compose');
|
||||
setShowComposer(true);
|
||||
if (isMobile) setActiveView('viewer');
|
||||
void withComposeGate('compose', {}, () => {
|
||||
startFreshComposerSession();
|
||||
setComposerMode('compose');
|
||||
setShowComposer(true);
|
||||
if (isMobile) setActiveView('viewer');
|
||||
});
|
||||
}}
|
||||
className={cn(
|
||||
"absolute z-40 shadow-lg",
|
||||
@@ -4828,9 +4923,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
}
|
||||
}}
|
||||
onCompose={() => {
|
||||
startFreshComposerSession();
|
||||
setComposerMode('compose');
|
||||
setShowComposer(true);
|
||||
void withComposeGate('compose', {}, () => {
|
||||
startFreshComposerSession();
|
||||
setComposerMode('compose');
|
||||
setShowComposer(true);
|
||||
});
|
||||
}}
|
||||
currentUserEmail={client?.getUsername()}
|
||||
currentUserName={client?.getUsername()?.split("@")[0]}
|
||||
|
||||
@@ -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();
|
||||
@@ -217,6 +219,9 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
|
||||
replyTo={data.replyTo}
|
||||
initialDraftText={data.initialDraftText}
|
||||
initialData={data.initialData}
|
||||
prefill={data.prefill}
|
||||
sessionKey={`pro-${tabId}-${data.sessionId}`}
|
||||
sourceEmailId={data.sourceEmailId ?? undefined}
|
||||
composeFromAccountEmail={composeFromAccountEmail}
|
||||
onSend={handleSend}
|
||||
onScheduledSendCreated={handleScheduledSendCreated}
|
||||
|
||||
@@ -11,7 +11,9 @@ import { useProMultiAccountIdentities } from "@/hooks/use-pro-multi-account-iden
|
||||
import { findDraftIdentityId } from "@/lib/reply-identity";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { useProTabStore, type ProEmailTabData, type ProReplyContext } from "@/stores/pro-tab-store";
|
||||
import { useProTabStore, type ProComposeTabData, type ProEmailTabData, type ProReplyContext } from "@/stores/pro-tab-store";
|
||||
import { emailHooks } from "@/lib/plugin-hooks";
|
||||
import { composeOptionsFor, runBeforeCompose, type ComposePrefill } from "@/lib/plugin-compose-gate";
|
||||
import type { Email } from "@/lib/jmap/types";
|
||||
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||
import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
|
||||
@@ -92,12 +94,38 @@ export function ProEmailView({ emailId, client: clientOverride, accountId, onLoa
|
||||
const identities = useIdentityStore((s) => s.identities);
|
||||
const multiAccountIdentities = useProMultiAccountIdentities();
|
||||
|
||||
const openComposeTab = useProTabStore((s) => s.openComposeTab);
|
||||
const openComposeTabNow = useProTabStore((s) => s.openComposeTab);
|
||||
|
||||
const [email, setEmail] = useState<Email | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const composerSessionIdRef = useRef(0);
|
||||
|
||||
// Every compose tab opened from here asks onBeforeCompose first: a plugin
|
||||
// may stop it or prefill it. Opens straight away when nobody listens.
|
||||
const openComposeTab = useCallback((
|
||||
data: Omit<ProComposeTabData, 'sessionId'>,
|
||||
fields: Omit<NonNullable<Parameters<typeof composeOptionsFor>[1]>, 'ownEmails' | 'replyPrefix' | 'forwardPrefix'>,
|
||||
onOpened?: () => void,
|
||||
) => {
|
||||
const open = (prefill: ComposePrefill | null) => {
|
||||
composerSessionIdRef.current += 1;
|
||||
openComposeTabNow({ ...data, sessionId: composerSessionIdRef.current, prefill });
|
||||
onOpened?.();
|
||||
};
|
||||
if (emailHooks.onBeforeCompose.size === 0) {
|
||||
open(null);
|
||||
return;
|
||||
}
|
||||
void runBeforeCompose(composeOptionsFor(data.mode, {
|
||||
...fields,
|
||||
ownEmails: identities.map((i) => i.email).filter((e): e is string => Boolean(e)),
|
||||
replyPrefix: t('email_composer.prefix.reply'),
|
||||
forwardPrefix: t('email_composer.prefix.forward'),
|
||||
})).then((prefill) => {
|
||||
if (prefill) open(Object.keys(prefill).length > 0 ? prefill : null);
|
||||
});
|
||||
}, [identities, openComposeTabNow, t]);
|
||||
|
||||
// Held in a ref so a host passing an inline callback doesn't refetch the
|
||||
// message on every render.
|
||||
const onLoadedRef = useRef(onLoaded);
|
||||
@@ -134,39 +162,33 @@ export function ProEmailView({ emailId, client: clientOverride, accountId, onLoa
|
||||
|
||||
const handleReply = useCallback((draftText?: string) => {
|
||||
if (!email) return;
|
||||
composerSessionIdRef.current += 1;
|
||||
openComposeTab({
|
||||
sessionId: composerSessionIdRef.current,
|
||||
mode: 'reply',
|
||||
replyTo: buildReplyContext(email),
|
||||
sourceEmailId: email.id,
|
||||
initialDraftText: draftText,
|
||||
title: buildReplySubject(email.subject || t('email_composer.new_message'), t('email_composer.prefix.reply')),
|
||||
});
|
||||
}, { source: email, body: draftText ?? '' });
|
||||
}, [email, openComposeTab, t]);
|
||||
|
||||
const handleReplyAll = useCallback(() => {
|
||||
if (!email) return;
|
||||
composerSessionIdRef.current += 1;
|
||||
openComposeTab({
|
||||
sessionId: composerSessionIdRef.current,
|
||||
mode: 'replyAll',
|
||||
replyTo: buildReplyContext(email),
|
||||
sourceEmailId: email.id,
|
||||
title: buildReplySubject(email.subject || t('email_composer.new_message'), t('email_composer.prefix.reply')),
|
||||
});
|
||||
}, { source: email });
|
||||
}, [email, openComposeTab, t]);
|
||||
|
||||
const handleForward = useCallback(() => {
|
||||
if (!email) return;
|
||||
composerSessionIdRef.current += 1;
|
||||
openComposeTab({
|
||||
sessionId: composerSessionIdRef.current,
|
||||
mode: 'forward',
|
||||
replyTo: buildReplyContext(email),
|
||||
sourceEmailId: email.id,
|
||||
title: buildForwardSubject(email.subject || t('email_composer.new_message'), t('email_composer.prefix.forward')),
|
||||
});
|
||||
}, { source: email });
|
||||
}, [email, openComposeTab, t]);
|
||||
|
||||
// Mirrors handleForward, but attaches the original as a message/rfc822
|
||||
@@ -194,9 +216,7 @@ export function ProEmailView({ emailId, client: clientOverride, accountId, onLoa
|
||||
});
|
||||
if (!payload) return;
|
||||
|
||||
composerSessionIdRef.current += 1;
|
||||
openComposeTab({
|
||||
sessionId: composerSessionIdRef.current,
|
||||
mode: 'forward',
|
||||
replyTo: {
|
||||
subject: email.subject,
|
||||
@@ -210,7 +230,7 @@ export function ProEmailView({ emailId, client: clientOverride, accountId, onLoa
|
||||
// above uses - reusing payload.subject here would give the tab an
|
||||
// empty title instead of e.g. "Fwd: New message".
|
||||
title: buildForwardSubject(email.subject || t('email_composer.new_message'), t('email_composer.prefix.forward')),
|
||||
});
|
||||
}, { subject: payload.subject });
|
||||
}, [email, openComposeTab, t]);
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
@@ -339,9 +359,9 @@ export function ProEmailView({ emailId, client: clientOverride, accountId, onLoa
|
||||
: identities;
|
||||
const matchedIdentityId = findDraftIdentityId(composerIdentities, email.from?.[0]);
|
||||
|
||||
composerSessionIdRef.current += 1;
|
||||
const draftSubject = email.subject && email.subject !== t('email_composer.no_subject') ? email.subject : '';
|
||||
const addresses = (list: Email['to']) => (list ?? []).map((a) => a.email).filter(Boolean);
|
||||
openComposeTab({
|
||||
sessionId: composerSessionIdRef.current,
|
||||
mode: 'compose',
|
||||
title: email.subject || t('email_composer.new_message'),
|
||||
initialData: {
|
||||
@@ -363,8 +383,7 @@ export function ProEmailView({ emailId, client: clientOverride, accountId, onLoa
|
||||
// composer without its files and the next save stripped them.
|
||||
attachments: email.attachments,
|
||||
},
|
||||
});
|
||||
onClose();
|
||||
}, { to: addresses(email.to), cc: addresses(email.cc), subject: draftSubject, body: bodyText }, onClose);
|
||||
}, [email, identities, multiAccountIdentities, openComposeTab, onClose, t]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -165,8 +165,7 @@ export function Avatar({ name, email, contactPhotoUri, size = "md", className, d
|
||||
setPluginAvatarFailed(false);
|
||||
if (!email || avatarHooks.onAvatarResolve.size === 0) return;
|
||||
let cancelled = false;
|
||||
avatarHooks.onAvatarResolve
|
||||
.transform(null as string | null, { email, name })
|
||||
avatarHooks.onAvatarResolve.transform(null as string | null, { email, name })
|
||||
.then((url) => { if (!cancelled) setPluginAvatarUrl(url); })
|
||||
.catch(() => { if (!cancelled) setPluginAvatarFailed(true); });
|
||||
return () => { cancelled = true; };
|
||||
|
||||
@@ -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,78 @@
|
||||
// @vitest-environment node
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { emailHooks, clearAllHooks } from '../plugin-hooks';
|
||||
import { runBeforeCompose, composeOptionsFor, acceptComposeOptions } from '../plugin-compose-gate';
|
||||
import { formatMailto, parseMailto } from '../protocol-handlers/mailto';
|
||||
|
||||
beforeEach(() => clearAllHooks());
|
||||
|
||||
const blank = { mode: 'new' as const, to: [], cc: [], subject: '', body: '' };
|
||||
|
||||
describe('runBeforeCompose', () => {
|
||||
it('lets the composer open with no prefill when nobody listens', async () => {
|
||||
expect(await runBeforeCompose(blank)).toEqual({});
|
||||
});
|
||||
|
||||
it('stops the composer when a plugin returns false', async () => {
|
||||
emailHooks.onBeforeCompose.register('p', () => false);
|
||||
expect(await runBeforeCompose(blank)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns only the fields a plugin changed', async () => {
|
||||
const handler = vi.fn((o: Record<string, unknown>) => ({ ...o, subject: 'Status', body: 'Hi team,\n' }));
|
||||
emailHooks.onBeforeCompose.register('p', handler);
|
||||
const opts = { mode: 'reply' as const, to: ['alice@example.com'], cc: [], subject: 'Re: Plans', body: '' };
|
||||
expect(await runBeforeCompose(opts)).toEqual({ subject: 'Status', body: 'Hi team,\n' });
|
||||
expect(handler).toHaveBeenCalledWith(opts);
|
||||
});
|
||||
});
|
||||
|
||||
describe('acceptComposeOptions', () => {
|
||||
it('keeps clean strings and ignores the wrong types', () => {
|
||||
const out = acceptComposeOptions({
|
||||
to: ['a@example.com', 42, ' ', 'b@example.com\r\nBcc: evil@example.com'],
|
||||
cc: 'not a list',
|
||||
subject: 'Hello\nworld',
|
||||
body: 'Line 1\r\nLine 2\u0007',
|
||||
mode: 'forward',
|
||||
}, blank);
|
||||
expect(out).toEqual({
|
||||
mode: 'new',
|
||||
to: ['a@example.com', 'b@example.comBcc: evil@example.com'],
|
||||
cc: [],
|
||||
subject: 'Helloworld',
|
||||
body: 'Line 1\nLine 2',
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores anything but an object', () => {
|
||||
expect(acceptComposeOptions('x', blank)).toBeUndefined();
|
||||
expect(acceptComposeOptions([], blank)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('composeOptionsFor', () => {
|
||||
it('describes a reply the way the composer fills it in', () => {
|
||||
const source = {
|
||||
from: [{ name: 'Alice', email: 'alice@example.com' }],
|
||||
to: [{ email: 'me@example.com' }, { email: 'bob@example.com' }],
|
||||
cc: [{ email: 'carol@example.com' }],
|
||||
subject: 'Plans',
|
||||
};
|
||||
expect(composeOptionsFor('replyAll', { source, ownEmails: ['me@example.com'], replyPrefix: 'Re:' })).toEqual({
|
||||
mode: 'reply-all',
|
||||
to: ['Alice <alice@example.com>', 'bob@example.com'],
|
||||
cc: ['carol@example.com'],
|
||||
subject: 'Re: Plans',
|
||||
body: '',
|
||||
});
|
||||
expect(composeOptionsFor('forward', { source, forwardPrefix: 'Fwd:' })).toMatchObject({ mode: 'forward', to: [], subject: 'Fwd: Plans' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatMailto', () => {
|
||||
it('round-trips through parseMailto', () => {
|
||||
const fields = { to: ['a@example.com', 'b@example.com'], cc: ['c@example.com'], bcc: [], subject: 'Hi & bye', body: 'Line\nnext' };
|
||||
expect(parseMailto(formatMailto(fields))).toEqual(fields);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
// @vitest-environment node
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { clearAllHooks, uiHooks, pluginErrorTracker } from '../plugin-hooks';
|
||||
import { interceptWithResult, acceptLinkRewrite, resolveExternalLink } from '../plugin-hook-result';
|
||||
|
||||
const plainIntercept = (v: unknown) => uiHooks.onBeforeExternalLink.intercept(v);
|
||||
|
||||
beforeEach(() => {
|
||||
clearAllHooks();
|
||||
pluginErrorTracker.resetAll();
|
||||
});
|
||||
|
||||
describe('interceptWithResult', () => {
|
||||
it('lets the action through unchanged without handlers', async () => {
|
||||
const ctx = { href: 'https://a.example/' };
|
||||
expect(await interceptWithResult(uiHooks.onBeforeExternalLink, ctx, acceptLinkRewrite, plainIntercept))
|
||||
.toEqual({ allowed: true, value: ctx });
|
||||
});
|
||||
|
||||
it('stops on false and does not ask later handlers', async () => {
|
||||
const later = vi.fn();
|
||||
uiHooks.onBeforeExternalLink.register('a', () => false);
|
||||
uiHooks.onBeforeExternalLink.register('b', later);
|
||||
const out = await interceptWithResult(uiHooks.onBeforeExternalLink, { href: 'https://a.example/' }, acceptLinkRewrite, plainIntercept);
|
||||
expect(out).toEqual({ allowed: false });
|
||||
expect(later).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('takes a returned { href } and hands it to the next handler', async () => {
|
||||
const second = vi.fn(() => undefined);
|
||||
uiHooks.onBeforeExternalLink.register('a', () => ({ href: 'https://proxy.example/?u=1' }));
|
||||
uiHooks.onBeforeExternalLink.register('b', second);
|
||||
const out = await interceptWithResult(uiHooks.onBeforeExternalLink, { href: 'https://a.example/', emailId: 'e1' }, acceptLinkRewrite, plainIntercept);
|
||||
expect(out).toEqual({ allowed: true, value: { href: 'https://proxy.example/?u=1', emailId: 'e1' } });
|
||||
expect(second).toHaveBeenCalledWith({ href: 'https://proxy.example/?u=1', emailId: 'e1' });
|
||||
});
|
||||
|
||||
it('calls every handler once', async () => {
|
||||
const handler = vi.fn(() => ({ href: 'https://b.example/' }));
|
||||
uiHooks.onBeforeExternalLink.register('a', handler);
|
||||
await interceptWithResult(uiHooks.onBeforeExternalLink, { href: 'https://a.example/' }, acceptLinkRewrite, plainIntercept);
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('skips a handler that throws (fails open)', async () => {
|
||||
uiHooks.onBeforeExternalLink.register('a', () => { throw new Error('boom'); });
|
||||
const out = await interceptWithResult(uiHooks.onBeforeExternalLink, { href: 'https://a.example/' }, acceptLinkRewrite, plainIntercept);
|
||||
expect(out).toEqual({ allowed: true, value: { href: 'https://a.example/' } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('acceptLinkRewrite', () => {
|
||||
const cur = { href: 'https://a.example/' };
|
||||
it.each([
|
||||
'javascript:alert(1)',
|
||||
'data:text/html,hi',
|
||||
'/settings',
|
||||
'mailto:x@example.com',
|
||||
'https://',
|
||||
])('ignores %s', (href) => {
|
||||
expect(acceptLinkRewrite({ href }, cur)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ignores anything but an object with a string href', () => {
|
||||
expect(acceptLinkRewrite('https://b.example/', cur)).toBeUndefined();
|
||||
expect(acceptLinkRewrite({ href: 42 }, cur)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('takes an http(s) address', () => {
|
||||
expect(acceptLinkRewrite({ href: ' HTTP://b.example/x ' }, cur)).toEqual({ href: 'HTTP://b.example/x' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveExternalLink (a link clicked in a message)', () => {
|
||||
const ctx = { href: 'https://a.example/', emailId: 'e1' };
|
||||
|
||||
it('opens the original address without plugins', async () => {
|
||||
expect(await resolveExternalLink(ctx)).toBe('https://a.example/');
|
||||
});
|
||||
|
||||
it('opens the address a plugin returns', async () => {
|
||||
const handler = vi.fn(() => ({ href: 'https://proxy.example/?u=a' }));
|
||||
uiHooks.onBeforeExternalLink.register('p', handler);
|
||||
expect(await resolveExternalLink(ctx)).toBe('https://proxy.example/?u=a');
|
||||
expect(handler).toHaveBeenCalledWith(ctx);
|
||||
});
|
||||
|
||||
it('opens nothing when a plugin returns false', async () => {
|
||||
uiHooks.onBeforeExternalLink.register('p', () => false);
|
||||
expect(await resolveExternalLink(ctx)).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the original when the rewrite is not a web address', async () => {
|
||||
uiHooks.onBeforeExternalLink.register('p', () => ({ href: 'javascript:alert(1)' }));
|
||||
expect(await resolveExternalLink(ctx)).toBe('https://a.example/');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
// @vitest-environment node
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { emailHooks, clearAllHooks } from '../plugin-hooks';
|
||||
import { emitThreadExpand } from '../plugin-thread-expand';
|
||||
|
||||
beforeEach(() => clearAllHooks());
|
||||
|
||||
describe('emitThreadExpand', () => {
|
||||
it('reports the thread id and its messages', () => {
|
||||
const handler = vi.fn();
|
||||
emailHooks.onThreadExpand.register('test', handler);
|
||||
emitThreadExpand('acct/x:T1', [{ id: 'a', threadId: 'T1' }, { id: 'b', threadId: 'T1' }], true);
|
||||
expect(handler).toHaveBeenCalledWith({ threadId: 'T1', emailIds: ['a', 'b'], expanded: true });
|
||||
});
|
||||
|
||||
it('falls back to the id inside an account-scoped thread key', () => {
|
||||
const handler = vi.fn();
|
||||
emailHooks.onThreadExpand.register('test', handler);
|
||||
emitThreadExpand('acct/x:T1', [], false);
|
||||
expect(handler).toHaveBeenCalledWith({ threadId: 'T1', emailIds: [], expanded: false });
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// onBeforeCompose: asked before the composer opens, from any entry point
|
||||
// (new message, keyboard, mailto, reply / forward, re-opened draft). A plugin
|
||||
// returns false to stop it, or a ComposeOptions to prefill the fields. Only
|
||||
// the fields a plugin actually changed come back as a prefill, so a reply's
|
||||
// own recipients and quote stay as the host builds them unless one is touched.
|
||||
|
||||
import type { ComposeOptions } from './plugin-types';
|
||||
import { emailHooks } from './plugin-hooks';
|
||||
import { interceptWithResult } from './plugin-hook-result';
|
||||
import { buildReplyRecipients, type ReplySource } from './reply-recipients';
|
||||
import { buildForwardSubject, buildReplySubject } from './subject-prefix';
|
||||
import { formatRecipient } from './email-composer-utils';
|
||||
|
||||
export type ComposerOpenMode = 'compose' | 'reply' | 'replyAll' | 'forward';
|
||||
|
||||
/** Fields a plugin changed in onBeforeCompose. Body is plain text. */
|
||||
export interface ComposePrefill {
|
||||
to?: string[];
|
||||
cc?: string[];
|
||||
subject?: string;
|
||||
body?: string;
|
||||
}
|
||||
|
||||
const MAX_RECIPIENTS = 200;
|
||||
const MAX_ADDRESS_LENGTH = 998;
|
||||
const MAX_SUBJECT_LENGTH = 998;
|
||||
const MAX_BODY_LENGTH = 64 * 1024;
|
||||
// eslint-disable-next-line no-control-regex -- removing control characters is the point
|
||||
const CONTROL_CHARS = /[\u0000-\u001f\u007f]/g;
|
||||
// eslint-disable-next-line no-control-regex -- removing control characters is the point
|
||||
const CONTROL_CHARS_EXCEPT_LINE_BREAKS = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g;
|
||||
|
||||
function cleanAddresses(value: unknown): string[] | undefined {
|
||||
if (!Array.isArray(value)) return undefined;
|
||||
return value
|
||||
.filter((v): v is string => typeof v === 'string')
|
||||
.map((v) => v.replace(CONTROL_CHARS, '').trim().slice(0, MAX_ADDRESS_LENGTH))
|
||||
.filter(Boolean)
|
||||
.slice(0, MAX_RECIPIENTS);
|
||||
}
|
||||
|
||||
/** `accept` for onBeforeCompose: take the well-formed fields of a returned ComposeOptions. */
|
||||
export function acceptComposeOptions(result: unknown, current: ComposeOptions): ComposeOptions | undefined {
|
||||
if (!result || typeof result !== 'object' || Array.isArray(result)) return undefined;
|
||||
const r = result as Record<string, unknown>;
|
||||
const next: ComposeOptions = { ...current };
|
||||
const to = cleanAddresses(r.to);
|
||||
if (to) next.to = to;
|
||||
const cc = cleanAddresses(r.cc);
|
||||
if (cc) next.cc = cc;
|
||||
if (typeof r.subject === 'string') next.subject = r.subject.replace(CONTROL_CHARS, '').slice(0, MAX_SUBJECT_LENGTH);
|
||||
if (typeof r.body === 'string') {
|
||||
next.body = r.body.replace(/\r\n?/g, '\n').replace(CONTROL_CHARS_EXCEPT_LINE_BREAKS, '').slice(0, MAX_BODY_LENGTH);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
const sameList = (a: string[], b: string[]) => a.length === b.length && a.every((v, i) => v === b[i]);
|
||||
|
||||
/**
|
||||
* Run onBeforeCompose. Resolves to null when a plugin stopped the composer,
|
||||
* otherwise to the fields it changed (empty when none).
|
||||
*/
|
||||
export async function runBeforeCompose(options: ComposeOptions): Promise<ComposePrefill | null> {
|
||||
const outcome = await interceptWithResult(emailHooks.onBeforeCompose, options, acceptComposeOptions, (o) => emailHooks.onBeforeCompose.intercept(o));
|
||||
if (!outcome.allowed) return null;
|
||||
const changed = outcome.value;
|
||||
const prefill: ComposePrefill = {};
|
||||
if (!sameList(changed.to, options.to)) prefill.to = changed.to;
|
||||
if (!sameList(changed.cc, options.cc)) prefill.cc = changed.cc;
|
||||
if (changed.subject !== options.subject) prefill.subject = changed.subject;
|
||||
if (changed.body !== options.body) prefill.body = changed.body;
|
||||
return prefill;
|
||||
}
|
||||
|
||||
const PLUGIN_MODE: Record<ComposerOpenMode, ComposeOptions['mode']> = {
|
||||
compose: 'new',
|
||||
reply: 'reply',
|
||||
replyAll: 'reply-all',
|
||||
forward: 'forward',
|
||||
};
|
||||
|
||||
/**
|
||||
* The ComposeOptions for a composer about to open: what the host will fill in
|
||||
* itself. A reply's recipients and subject are worked out the way the
|
||||
* composer does it.
|
||||
*/
|
||||
export function composeOptionsFor(
|
||||
mode: ComposerOpenMode,
|
||||
opts: {
|
||||
source?: (ReplySource & { subject?: string }) | null;
|
||||
ownEmails?: string[];
|
||||
to?: string[];
|
||||
cc?: string[];
|
||||
subject?: string;
|
||||
body?: string;
|
||||
replyPrefix?: string;
|
||||
forwardPrefix?: string;
|
||||
} = {},
|
||||
): ComposeOptions {
|
||||
let to = opts.to ?? [];
|
||||
let cc = opts.cc ?? [];
|
||||
let subject = opts.subject ?? '';
|
||||
if (opts.source && (mode === 'reply' || mode === 'replyAll')) {
|
||||
const recipients = buildReplyRecipients(opts.source, mode, opts.ownEmails ?? []);
|
||||
if (!opts.to) to = recipients.to.map((r) => formatRecipient(r.name, r.email ?? ''));
|
||||
if (!opts.cc) cc = recipients.cc.map((r) => formatRecipient(r.name, r.email ?? ''));
|
||||
if (opts.subject === undefined && opts.source.subject) subject = buildReplySubject(opts.source.subject, opts.replyPrefix ?? 'Re:');
|
||||
} else if (opts.source && mode === 'forward' && opts.subject === undefined && opts.source.subject) {
|
||||
subject = buildForwardSubject(opts.source.subject, opts.forwardPrefix ?? 'Fwd:');
|
||||
}
|
||||
return { mode: PLUGIN_MODE[mode], to, cc, subject, body: opts.body ?? '' };
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// Composer lifecycle hooks: onComposerOpen, onComposerClose, onDraftAutoSave.
|
||||
//
|
||||
// The composer component is not the composer session: the host remounts it
|
||||
// when it moves (Mountain View's pop-out window, a layout switch), which is
|
||||
// not the user closing and reopening it. So a session is identified by a key
|
||||
// the host keeps for as long as it means the same message, and an unmount
|
||||
// only counts as a close when no composer with that key mounts again right
|
||||
// after it.
|
||||
//
|
||||
// Observers never get in the way of the composer: a missing or broken bus is
|
||||
// ignored here (composer tests replace the hook module with partial doubles).
|
||||
|
||||
import type { ComposerContext } from './plugin-types';
|
||||
import { emailHooks } from './plugin-hooks';
|
||||
|
||||
const openSessions = new Set<string>();
|
||||
const pendingCloses = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
/** A composer with this session key is on screen. */
|
||||
export function composerMounted(sessionKey: string, context: ComposerContext): void {
|
||||
const pending = pendingCloses.get(sessionKey);
|
||||
if (pending !== undefined) {
|
||||
// Remounted in place: the same session goes on.
|
||||
clearTimeout(pending);
|
||||
pendingCloses.delete(sessionKey);
|
||||
return;
|
||||
}
|
||||
if (openSessions.has(sessionKey)) return;
|
||||
openSessions.add(sessionKey);
|
||||
try {
|
||||
if (emailHooks.onComposerOpen.size > 0) void emailHooks.onComposerOpen.emit(context);
|
||||
} catch { /* observers never break the composer */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* The composer with this key went away. Reported as closed unless it mounts
|
||||
* again before the next task. `info` is read when the close is reported, so a
|
||||
* send that finishes while the host tears the composer down still counts.
|
||||
*/
|
||||
export function composerUnmounted(sessionKey: string, info: () => { draftId: string | null; sent: boolean }): void {
|
||||
if (!openSessions.has(sessionKey) || pendingCloses.has(sessionKey)) return;
|
||||
pendingCloses.set(sessionKey, setTimeout(() => {
|
||||
pendingCloses.delete(sessionKey);
|
||||
openSessions.delete(sessionKey);
|
||||
try {
|
||||
if (emailHooks.onComposerClose.size > 0) void emailHooks.onComposerClose.emit(info());
|
||||
} catch { /* observers never break the composer */ }
|
||||
}, 0));
|
||||
}
|
||||
|
||||
/** A draft was saved on the server. */
|
||||
export function draftSaved(draftId: string, subject: string): void {
|
||||
try {
|
||||
if (emailHooks.onDraftAutoSave.size > 0) void emailHooks.onDraftAutoSave.emit({ draftId, subject });
|
||||
} catch { /* observers never break the composer */ }
|
||||
}
|
||||
|
||||
/** Forget every session (tests). */
|
||||
export function resetComposerSessions(): void {
|
||||
for (const timer of pendingCloses.values()) clearTimeout(timer);
|
||||
pendingCloses.clear();
|
||||
openSessions.clear();
|
||||
}
|
||||
|
||||
/** The plugin-facing name of a composer mode. */
|
||||
export function composerContextMode(mode: 'compose' | 'reply' | 'replyAll' | 'forward'): ComposerContext['mode'] {
|
||||
return mode === 'compose' ? 'new' : mode === 'replyAll' ? 'reply-all' : mode;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
// "Before" hooks whose handlers may either stop an action (return false) or
|
||||
// change what it does (return a value), such as onBeforeExternalLink
|
||||
// (`{ href }`) and onBeforeCompose (a ComposeOptions to prefill).
|
||||
//
|
||||
// HookBus.intercept() only reports whether the action may go ahead and drops
|
||||
// every other return value; HookBus.transform() keeps return values but
|
||||
// ignores `false`. Running both would call every plugin twice (and show any
|
||||
// confirmation dialog twice), so this walks the handlers once and honours
|
||||
// both answers. A payload changed in place has no effect: plugin handlers run
|
||||
// in a sandboxed iframe and receive a copy.
|
||||
|
||||
import { pluginErrorTracker, uiHooks, type HookBus } from './plugin-hooks';
|
||||
import type { ExternalLinkContext } from './plugin-types';
|
||||
import { isOpenableLinkHref } from './email-sanitization';
|
||||
|
||||
// Same budget as HookBus.intercept: these hooks may wait on a dialog.
|
||||
const INTERCEPT_TIMEOUT_MS = 60_000;
|
||||
|
||||
interface Entry {
|
||||
pluginId: string;
|
||||
handler: (...args: unknown[]) => unknown;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function handlersOf(bus: HookBus<any>): Entry[] | null {
|
||||
const handlers = (bus as unknown as { handlers?: unknown }).handlers;
|
||||
return Array.isArray(handlers) ? [...(handlers as Entry[])] : null;
|
||||
}
|
||||
|
||||
function withTimeout<T>(value: T | Promise<T>, ms: number): Promise<T> {
|
||||
if (!(value instanceof Promise)) return Promise.resolve(value);
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error(`Hook timed out after ${ms}ms`)), ms);
|
||||
value.then(
|
||||
(v) => { clearTimeout(timer); resolve(v); },
|
||||
(e) => { clearTimeout(timer); reject(e); },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export type InterceptResult<V> = { allowed: false } | { allowed: true; value: V };
|
||||
|
||||
/**
|
||||
* Ask every handler of an intercept bus, in order. `false` stops the action;
|
||||
* any other return value goes through `accept`, which turns it into the next
|
||||
* value or returns undefined to ignore it. Like `intercept`, a handler that
|
||||
* throws or times out is skipped (fail open).
|
||||
*
|
||||
* `plainIntercept` is the bus's own `intercept`, used when its handlers can't
|
||||
* be walked (a test double): the veto still works, return values are lost.
|
||||
*/
|
||||
export async function interceptWithResult<V>(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
bus: HookBus<any>,
|
||||
value: V,
|
||||
accept: (result: unknown, current: V) => V | undefined,
|
||||
plainIntercept: (value: V) => Promise<boolean>,
|
||||
): Promise<InterceptResult<V>> {
|
||||
// No listeners (or a test double without a size): nothing to ask.
|
||||
if (!(bus && bus.size > 0)) return { allowed: true, value };
|
||||
const handlers = handlersOf(bus);
|
||||
if (!handlers) {
|
||||
return (await plainIntercept(value)) ? { allowed: true, value } : { allowed: false };
|
||||
}
|
||||
let current = value;
|
||||
for (const { pluginId, handler } of handlers) {
|
||||
if (pluginErrorTracker.isDisabled(pluginId)) continue;
|
||||
let result: unknown;
|
||||
try {
|
||||
result = await withTimeout(handler(current), INTERCEPT_TIMEOUT_MS);
|
||||
} catch (err) {
|
||||
pluginErrorTracker.record(pluginId, err);
|
||||
continue;
|
||||
}
|
||||
if (result === false) return { allowed: false };
|
||||
if (result === undefined || result === null || result === true) continue;
|
||||
const next = accept(result, current);
|
||||
if (next !== undefined) current = next;
|
||||
}
|
||||
return { allowed: true, value: current };
|
||||
}
|
||||
|
||||
/**
|
||||
* `accept` for onBeforeExternalLink: a handler may return `{ href }` to send
|
||||
* the click elsewhere. Only a web address is taken, so a plugin can't turn a
|
||||
* link into javascript:, data: or a path inside the app.
|
||||
*/
|
||||
export function acceptLinkRewrite<T extends { href: string }>(result: unknown, current: T): T | undefined {
|
||||
if (!result || typeof result !== 'object') return undefined;
|
||||
const href = (result as { href?: unknown }).href;
|
||||
if (typeof href !== 'string') return undefined;
|
||||
const trimmed = href.trim();
|
||||
if (!/^https?:/i.test(trimmed)) return undefined;
|
||||
try {
|
||||
new URL(trimmed);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
return { ...current, href: trimmed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask onBeforeExternalLink about a click on a link in a message. Resolves to
|
||||
* the address to open: the original, the one a plugin rewrote it to, or null
|
||||
* when a plugin stopped it.
|
||||
*/
|
||||
export async function resolveExternalLink(ctx: ExternalLinkContext): Promise<string | null> {
|
||||
const outcome = await interceptWithResult(uiHooks.onBeforeExternalLink, ctx, acceptLinkRewrite, (c) => uiHooks.onBeforeExternalLink.intercept(c));
|
||||
if (!outcome.allowed || !isOpenableLinkHref(outcome.value.href)) return null;
|
||||
return outcome.value.href;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// onThreadExpand: a conversation was expanded or collapsed, in the message
|
||||
// list or in the reading pane. Shared by every place that folds a thread so
|
||||
// they all report the same payload.
|
||||
|
||||
import type { Email } from './jmap/types';
|
||||
import { emailHooks } from './plugin-hooks';
|
||||
import { threadIdFromKey } from './thread-utils';
|
||||
|
||||
/**
|
||||
* Tell plugins a conversation was expanded or collapsed. `emails` are the
|
||||
* messages that were unfolded or folded: the whole thread for a list row, one
|
||||
* message for a card in the reading pane.
|
||||
*/
|
||||
export function emitThreadExpand(
|
||||
threadKey: string,
|
||||
emails: ReadonlyArray<Pick<Email, 'id' | 'threadId'>>,
|
||||
expanded: boolean,
|
||||
): void {
|
||||
if (emailHooks.onThreadExpand.size === 0) return;
|
||||
const threadId = emails[0]?.threadId || threadIdFromKey(threadKey);
|
||||
void emailHooks.onThreadExpand.emit({ threadId, emailIds: emails.map((e) => e.id), expanded });
|
||||
}
|
||||
@@ -117,6 +117,23 @@ export function parseMailto(raw: string): ParsedMailto | null {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A mailto: URL for parsed fields, for hooks that report the link when the
|
||||
* original URL is gone (a request that came through the OS handler).
|
||||
*/
|
||||
export function formatMailto(fields: Pick<ParsedMailto, "to"> & Partial<Omit<ParsedMailto, "to">>): string {
|
||||
const params: string[] = [];
|
||||
const add = (key: string, value: string | undefined) => {
|
||||
if (value) params.push(`${key}=${encodeURIComponent(value)}`);
|
||||
};
|
||||
add("cc", fields.cc?.join(","));
|
||||
add("bcc", fields.bcc?.join(","));
|
||||
add("subject", fields.subject);
|
||||
add("body", fields.body);
|
||||
const to = fields.to.map(encodeURIComponent).join(",");
|
||||
return `mailto:${to}${params.length ? `?${params.join("&")}` : ""}`;
|
||||
}
|
||||
|
||||
/** Window event carrying a parsed in-app mailto request to the mail page. */
|
||||
export const INTERNAL_MAILTO_EVENT = "bulwark:mailto";
|
||||
|
||||
@@ -137,7 +154,7 @@ export function requestInternalMailto(raw: string): boolean {
|
||||
// The listener signals "handled" by cancelling the event, which makes
|
||||
// dispatchEvent report false - so an uncancelled dispatch means nobody took it.
|
||||
const uncancelled = window.dispatchEvent(
|
||||
new CustomEvent<ParsedMailto>(INTERNAL_MAILTO_EVENT, { detail: parsed, cancelable: true }),
|
||||
new CustomEvent<ParsedMailto & { href: string }>(INTERNAL_MAILTO_EVENT, { detail: { ...parsed, href: raw }, cancelable: true }),
|
||||
);
|
||||
return !uncancelled;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type { ComposerDraftData } from '@/components/email/email-composer';
|
||||
import type { ComposePrefill } from '@/lib/plugin-compose-gate';
|
||||
import type { SearchScope } from '@/lib/global-search/query-parser';
|
||||
|
||||
export type ProTabKind =
|
||||
@@ -56,6 +57,8 @@ export interface ProComposeTabData {
|
||||
replyTo?: ProReplyContext;
|
||||
initialDraftText?: string;
|
||||
initialData?: ComposerDraftData | null;
|
||||
/** Fields a plugin filled in through onBeforeCompose; dropped on the first state save. */
|
||||
prefill?: ComposePrefill | null;
|
||||
sourceEmailId?: string | null;
|
||||
title: string;
|
||||
}
|
||||
@@ -664,7 +667,9 @@ export const useProTabStore = create<ProTabState>()(
|
||||
set({
|
||||
tabs: get().tabs.map((tab) => {
|
||||
if (tab.id !== id || tab.kind !== 'compose' || !tab.composeData) return tab;
|
||||
return { ...tab, composeData: { ...tab.composeData, initialData: draft } };
|
||||
// The draft now carries the user's own text: a remount must not
|
||||
// apply the plugin's prefill over it again.
|
||||
return { ...tab, composeData: { ...tab.composeData, initialData: draft, prefill: null } };
|
||||
}),
|
||||
});
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user