diff --git a/app/(main)/[locale]/layout.tsx b/app/(main)/[locale]/layout.tsx index 94ccd69b5..bc381b66c 100644 --- a/app/(main)/[locale]/layout.tsx +++ b/app/(main)/[locale]/layout.tsx @@ -11,6 +11,7 @@ import { ProInterfaceRedirect } from "@/components/pro/pro-interface-redirect"; import { ImpersonationReconciler } from "@/components/impersonation/impersonation-reconciler"; import { PluginDialogHost } from "@/components/plugins/plugin-dialog-host"; import { ExternalRecipientConfirmHost } from "@/components/email/external-recipient-confirm"; +import { PluginPageNavigator } from "@/components/plugins/plugin-page-navigator"; import { CommandPaletteHost } from "@/components/command-palette/command-palette"; import { TotpReauthDialog } from "@/components/totp-reauth-dialog"; import { PluginConnectDialog } from "@/components/plugins/plugin-connect-dialog"; @@ -82,6 +83,7 @@ export default async function LocaleLayout({ + {/* Lite is a static bundle: no server keeps an archive there. */} diff --git a/components/email/__tests__/composer-draft-attachments.test.tsx b/components/email/__tests__/composer-draft-attachments.test.tsx index d863bd453..7fd66bf6e 100644 --- a/components/email/__tests__/composer-draft-attachments.test.tsx +++ b/components/email/__tests__/composer-draft-attachments.test.tsx @@ -398,3 +398,56 @@ describe('a re-opened draft that lives in a group account', () => { expect(onDiscardDraft).toHaveBeenCalledWith('draft-v2', { accountId: undefined, localAccountId: undefined }); }); }); + +/** + * Send pressed while an autosave is still on the server and a later edit has + * armed the next one. The armed save must not fire beside the send: it would + * replace the version the send then destroys ("post-send cleanup failed") and + * leave its own version behind in Drafts. Seen with saves taking seconds. + */ +describe('send while a draft save is in flight', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + useAuthStore.setState({ client: null }); + vi.clearAllMocks(); + }); + + it('sends the version of the last save, made after the one in flight', async () => { + let finishFirstSave: (id: string) => void = () => {}; + const createDraft = vi.fn() + .mockImplementationOnce(() => new Promise((resolve) => { finishFirstSave = resolve; })) + .mockResolvedValueOnce('draft-v3'); + useAuthStore.setState({ + client: { createDraft, getEmail: vi.fn(), hasDelayedSend: () => false, getMaxDelayedSend: () => 0 } as never, + }); + const onSend = vi.fn().mockResolvedValue(undefined); + render(); + + // First autosave starts and hangs on the server. + fireEvent.change(screen.getByDisplayValue('Quarterly numbers'), { target: { value: 'Quarterly numbers v2' } }); + await act(async () => { await vi.advanceTimersByTimeAsync(2500); }); + expect(createDraft).toHaveBeenCalledTimes(1); + + // A last edit arms the next autosave, then Send right away. + fireEvent.change(screen.getByDisplayValue('Quarterly numbers v2'), { target: { value: 'Quarterly numbers v3' } }); + fireEvent.click(screen.getAllByTestId('composer-send')[0]); + // Long enough for the armed autosave to have fired. + await act(async () => { await vi.advanceTimersByTimeAsync(3000); }); + expect(onSend).not.toHaveBeenCalled(); + + await act(async () => { + finishFirstSave('draft-v2'); + await vi.advanceTimersByTimeAsync(0); + }); + + // One save after the first, replacing it, and the send carries its id. + expect(createDraft).toHaveBeenCalledTimes(2); + expect(createDraft.mock.calls[1][7]).toBe('draft-v2'); + expect(onSend).toHaveBeenCalledTimes(1); + expect(onSend).toHaveBeenCalledWith(expect.objectContaining({ draftId: 'draft-v3', subject: 'Quarterly numbers v3' })); + }); +}); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index e9162e452..75f1129d3 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -3337,31 +3337,29 @@ export function EmailComposer({ isSendingRef.current = true; setIsSending(true); - // Resolve the freshest draftId we can. Two cases: - // 1. An autosave is currently in flight - wait for it; don't issue a - // parallel destroy/create that would race with it on the same id. - // 2. A debounced save is scheduled (timer set) - cancel it and flush - // now so the latest body content lands on the server. - // Use draftIdRef (not the React state) because state updates from - // the in-flight save may not have rendered yet when we read here. + // Resolve the freshest draftId we can. A scheduled autosave is cancelled + // before anything is awaited and flushed behind the save in flight + // (saveDraft queues), so one wait covers both. Left armed, the timer + // could fire while the send waits and save beside it: the send then + // destroys a version that save already replaced, and the version it + // created stays in Drafts. On a slow server - seconds per save - this + // hit most sends typed up to the last moment. + // Read draftIdRef (not the React state) afterwards: the saves update it + // synchronously, before React re-renders. let finalDraftId = draftIdRef.current; - if (inflightSaveRef.current) { - try { - const savedId = await inflightSaveRef.current; - if (savedId) finalDraftId = savedId; - } catch (err) { - debug.error('In-flight draft save failed before send:', err); - } - } + let pendingSave = inflightSaveRef.current; if (saveTimeoutRef.current) { clearTimeout(saveTimeoutRef.current); saveTimeoutRef.current = null; + pendingSave = saveDraft(); + } + if (pendingSave) { try { - const savedId = await saveDraft(); - if (savedId) finalDraftId = savedId; + await pendingSave; } catch (err) { debug.error('Failed to save draft before send:', err); } + finalDraftId = draftIdRef.current; } const identityFromEmail = currentIdentity?.email diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index f5718c451..a3424e3a3 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -755,6 +755,7 @@ function mvSenderDate(date: Date | string, timeFormat: '12h' | '24h'): string { * snippet. Clicking it unfolds the message in place. */ function MvConversationCard({ email, onOpen, timeFormat, compact }: { email: Email; onOpen?: (email: Email) => void; timeFormat: '12h' | '24h'; compact?: boolean }) { + const tSidebar = useTranslations('sidebar'); const sender = email.from?.[0]; const unread = !email.keywords?.$seen; return ( @@ -771,6 +772,12 @@ function MvConversationCard({ email, onOpen, timeFormat, compact }: { email: Ema {sender?.name || sender?.email || ''} + {email.isScheduled && ( + + + {email.scheduledSendAt ? formatScheduledSendAt(email.scheduledSendAt, timeFormat) : tSidebar('scheduled')} + + )} {compact ? formatDate(emailDisplayDate(email)) : mvSenderDate(emailDisplayDate(email), timeFormat)} @@ -847,6 +854,7 @@ export function MvExpandedConversationCard({ blobSource?: EmailBlobContext; }) { const t = useTranslations('email_viewer'); + const tSidebar = useTranslations('sidebar'); const externalContent = useConversationExternalContent(); const sender = email.from?.[0]; const hasBody = !!email.bodyValues; @@ -885,6 +893,12 @@ export function MvExpandedConversationCard({ {sender?.name || sender?.email || ''} + {email.isScheduled && ( + + + {email.scheduledSendAt ? formatScheduledSendAt(email.scheduledSendAt, timeFormat) : tSidebar('scheduled')} + + )} {!compact && sender?.name && sender.email && ( <{sender.email}> )} @@ -920,6 +934,14 @@ export function MvExpandedConversationCard({ )} + {email.isScheduled && ( +
+ + + {t('scheduled_banner', { date: email.scheduledSendAt ? formatScheduledSendAt(email.scheduledSendAt, timeFormat) : '' })} + +
+ )} {showDetails && } {hasBody ? ( state.density); + const timeFormat = useSettingsStore((state) => state.timeFormat); const plugin = useConversationPluginProps(email); const sender = email.from?.[0]; const formatAddress = useOwnDomainAddress(); @@ -402,6 +405,12 @@ function EmailCard({ {sender?.name || (sender?.email && formatAddress(sender.email)) || t('email_viewer.unknown_sender')}
+ {email.isScheduled && ( + + + {t('sidebar.scheduled')} + + )} {isStarred && ( )} @@ -410,7 +419,14 @@ function EmailCard({ )}
- {formatDate(emailDisplayDate(email))} + {email.isScheduled && email.scheduledSendAt ? ( + + + {formatScheduledSendAt(email.scheduledSendAt, timeFormat)} + + ) : ( + formatDate(emailDisplayDate(email)) + )}
{!isExpanded && density !== 'extra-compact' && (

@@ -430,6 +446,14 @@ function EmailCard({ {/* Expanded Content */} {isExpanded && (

+ {email.isScheduled && ( +
+ + + {t('email_viewer.scheduled_banner', { date: email.scheduledSendAt ? formatScheduledSendAt(email.scheduledSendAt, timeFormat) : '' })} + +
+ )}
+ {email.isScheduled && } {isStarred && } {isAnswered && } {isForwarded && } @@ -278,6 +279,9 @@ export function ThreadEmailItem({ {/* Indicators */}
+ {email.isScheduled && ( + + )} {isStarred && ( )} diff --git a/components/files/__tests__/file-preview-modal-history.test.tsx b/components/files/__tests__/file-preview-modal-history.test.tsx index b29dcbbfb..fd7c0d356 100644 --- a/components/files/__tests__/file-preview-modal-history.test.tsx +++ b/components/files/__tests__/file-preview-modal-history.test.tsx @@ -204,3 +204,82 @@ describe('FilePreviewModal history lifecycle (modeled queued traversal)', () => expect(screen.queryByRole('dialog')).toBeNull(); }); }); + +// A forwarded certified-mail (PEC) message: postacert.eml wraps the original +// as msg.eml, which used to be download-only inside the .eml preview. +const INNER_EML = [ + 'From: Sender ', + 'To: me@example.com', + 'Subject: Inner original', + 'Date: Tue, 27 Oct 2026 20:36:28 +0100', + 'Content-Type: text/plain; charset=UTF-8', + '', + 'the original message', +].join('\r\n'); +const OUTER_EML = [ + 'From: Forwarder ', + 'To: me@example.com', + 'Subject: FWD: wrapper', + 'Date: Wed, 28 Oct 2026 05:50:39 +0100', + 'MIME-Version: 1.0', + 'Content-Type: multipart/mixed; boundary="b1"', + '', + '--b1', + 'Content-Type: text/plain; charset=UTF-8', + '', + 'see attached', + '--b1', + 'Content-Type: message/rfc822; name="msg.eml"', + 'Content-Disposition: attachment; filename="msg.eml"', + '', + INNER_EML, + '--b1--', + '', +].join('\r\n'); + +describe('FilePreviewModal attachment of a previewed .eml', () => { + async function openOuter() { + const getContent = vi.fn().mockResolvedValue({ + blob: new Blob([OUTER_EML], { type: 'message/rfc822' }), contentType: 'message/rfc822', + }); + const view = openPreview(false, getContent, 'postacert.eml'); + await advance(0); + await vi.waitFor(() => expect(screen.getByText('FWD: wrapper')).toBeInTheDocument()); + fireEvent.click(screen.getByText('msg.eml')); + await advance(0); + await vi.waitFor(() => expect(screen.getByText('Inner original')).toBeInTheDocument()); + return view; + } + + it('opens in a preview of its own in place of the outer one, with a Back entry', async () => { + await openOuter(); + await advance(0); + expect(screen.getAllByRole('dialog')).toHaveLength(1); + expect(screen.getByRole('dialog')).toHaveAccessibleName('msg.eml'); + expect(push).toHaveBeenCalledTimes(2); + }); + + it('Escape closes the inner preview only', async () => { + const { onClose } = await openOuter(); + fireEvent.keyDown(window, { key: 'Escape' }); + expect(screen.getByRole('dialog')).toHaveAccessibleName('postacert.eml'); + expect(screen.getByText('FWD: wrapper')).toBeInTheDocument(); + expect(onClose).not.toHaveBeenCalled(); + fireEvent.keyDown(window, { key: 'Escape' }); + expect(onClose).toHaveBeenCalledOnce(); + }); + + it('Browser Back closes the inner preview, the next Back the outer one', async () => { + const { onClose } = await openOuter(); + await advance(0); + window.history.back(); + await advance(10); + expect(screen.getByRole('dialog')).toHaveAccessibleName('postacert.eml'); + expect(onClose).not.toHaveBeenCalled(); + expect(window.history.state.__closeOnBack).toBeTruthy(); + window.history.back(); + await advance(10); + expect(onClose).toHaveBeenCalledOnce(); + expect(window.history.state).toEqual(originalState); + }); +}); diff --git a/components/files/eml-preview.tsx b/components/files/eml-preview.tsx index 27e73ddcf..0ff73b06e 100644 --- a/components/files/eml-preview.tsx +++ b/components/files/eml-preview.tsx @@ -5,6 +5,7 @@ import { Paperclip, Download } from "@/components/icons"; import { emailIframeCsp, sanitizeEmailBodyForIframe } from "@/lib/email-sanitization"; import { getEffectiveTimeZone } from "@/lib/timezone"; import { stripBidiControls } from "@/lib/bidi"; +import { isFilePreviewable } from "@/lib/file-preview"; import { useSettingsStore } from "@/stores/settings-store"; import { useNoThirdPartyTraffic } from "@/hooks/use-third-party-mode"; @@ -17,9 +18,11 @@ export type ParsedEml = { date?: string; html?: string; text?: string; - attachments?: Array<{ filename?: string; mimeType?: string; content?: ArrayBuffer | Uint8Array }>; + attachments?: Array; }; +export type EmlAttachment = { filename?: string; mimeType?: string; content?: ArrayBuffer | Uint8Array }; + function formatAddress(a?: { name?: string; address?: string }): string { if (!a) return ""; if (a.name && a.address) return `${a.name} <${a.address}>`; @@ -41,12 +44,19 @@ function escapeHtml(s: string): string { // scripts, no same-origin), so a script-bearing .eml can never execute in our // origin. Remote content is blocked unless the user allows it everywhere: // there is no per-message "load" here. Parsing happens in the caller -// (FilePreviewModal); this is pure presentation. -export function EmlPreview({ message }: { message: ParsedEml }) { +// (FilePreviewModal); this is pure presentation. An attachment the preview +// can show - often another message: a certified-mail (PEC) envelope wraps the +// original, and a forwarded PEC wraps that envelope - opens through +// onOpenAttachment, with download beside it. +export function EmlPreview({ message, onOpenAttachment }: { + message: ParsedEml; + onOpenAttachment?: (attachment: EmlAttachment) => void; +}) { const t = useTranslations("email_viewer"); // Always blocked with no third-party traffic (#903). const remoteBlockedByOrg = useNoThirdPartyTraffic(); const blockRemote = useSettingsStore((state) => state.externalContentPolicy) !== "allow" || remoteBlockedByOrg; + const tFiles = useTranslations("files"); // dir="auto" on a wrapper we control lets the first strong character pick // the direction, matching the main viewer. @@ -60,7 +70,7 @@ export function EmlPreview({ message }: { message: ParsedEml }) { ? `${body}` : ""; - const downloadAttachment = (att: NonNullable[number]) => { + const downloadAttachment = (att: EmlAttachment) => { if (!att.content) return; // content is a real ArrayBuffer/Uint8Array at runtime; cast for the strict // BlobPart lib type (Uint8Array vs ArrayBuffer). @@ -103,19 +113,47 @@ export function EmlPreview({ message }: { message: ParsedEml }) {
{t("attachments")}
- {message.attachments.map((att, i) => ( - - ))} + {message.attachments.map((att, i) => { + const name = att.filename ? stripBidiControls(att.filename) : "attachment"; + const opens = !!onOpenAttachment && !!att.content && isFilePreviewable(att.filename, att.mimeType); + if (!opens) { + return ( + + ); + } + return ( +
+ + +
+ ); + })}
)} diff --git a/components/files/file-preview-modal.tsx b/components/files/file-preview-modal.tsx index a1136b0d6..b1bbc10c1 100644 --- a/components/files/file-preview-modal.tsx +++ b/components/files/file-preview-modal.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useMemo } from "react"; import { useTranslations } from "next-intl"; import { ExternalLink } from "@/components/icons"; import { Spinner } from "@/components/ui/spinner"; @@ -9,9 +9,10 @@ import { IconButton } from "@/components/ui/icon-button"; import { ZoomableImage } from "@/components/files/zoomable-image"; import { getFilePreviewKind, imageBlobUrl, isMimeTypeSafeForInlinePreview, previewBlobType } from "@/lib/file-preview"; import dynamic from "next/dynamic"; -import { EmlPreview, type ParsedEml } from "@/components/files/eml-preview"; +import { EmlPreview, type EmlAttachment, type ParsedEml } from "@/components/files/eml-preview"; import { FilePreviewPluginSection } from "@/components/files/file-preview-plugin-section"; import { canOpenUrlInNewWindow } from "@/lib/standalone-display"; +import { stripBidiControls } from "@/lib/bidi"; import { emailIframeCsp, sanitizeEmailBodyForIframe } from "@/lib/email-sanitization"; import { useSettingsStore } from "@/stores/settings-store"; import { useCloseOnBack } from "@/hooks/use-close-on-back"; @@ -165,6 +166,10 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent, fi // Always blocked with no third-party traffic (#903). const remoteBlockedByOrg = useNoThirdPartyTraffic(); const blockRemote = useSettingsStore((state) => state.externalContentPolicy) !== "allow" || remoteBlockedByOrg; + // An attachment of the previewed .eml, shown in a preview of its own in + // place of this one. It holds a Back entry of its own, so Escape and Back + // close it first and come back here. + const [nestedPreview, setNestedPreview] = useState<{ name: string; blob: Blob } | null>(null); // Decide whether to render the PDF in a plain