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 && (
+
)}
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