This commit is contained in:
Linus Rath
2026-10-28 18:06:09 +01:00
23 changed files with 1002 additions and 96 deletions
+2
View File
@@ -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({
<PluginConsentDialog />
<PluginOAuthCallbackListener />
<PluginAppEvents />
<PluginPageNavigator />
<PWAInstallPrompt />
<PushNotificationPrompt />
{/* Lite is a static bundle: no server keeps an archive there. */}
@@ -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<string>((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(<EmailComposer initialData={{ ...REOPENED_DRAFT, attachments: [] }} onSend={onSend} />);
// 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' }));
});
});
+15 -17
View File
@@ -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
+22
View File
@@ -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
<span dir="auto" className={cn("text-sm truncate", unread ? "font-bold" : "font-semibold")}>
{sender?.name || sender?.email || ''}
</span>
{email.isScheduled && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[11px] font-medium bg-primary/10 text-primary shrink-0">
<CalendarClock className="w-3 h-3" />
{email.scheduledSendAt ? formatScheduledSendAt(email.scheduledSendAt, timeFormat) : tSidebar('scheduled')}
</span>
)}
<span className="ms-auto ps-2 text-xs text-muted-foreground whitespace-nowrap">
{compact ? formatDate(emailDisplayDate(email)) : mvSenderDate(emailDisplayDate(email), timeFormat)}
</span>
@@ -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({
<span className="flex-1 min-w-0">
<span className="flex items-baseline gap-2">
<span dir="auto" className="text-sm font-semibold truncate">{sender?.name || sender?.email || ''}</span>
{email.isScheduled && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[11px] font-medium bg-primary/10 text-primary shrink-0">
<CalendarClock className="w-3 h-3" />
{email.scheduledSendAt ? formatScheduledSendAt(email.scheduledSendAt, timeFormat) : tSidebar('scheduled')}
</span>
)}
{!compact && sender?.name && sender.email && (
<span className="text-xs text-muted-foreground truncate">&lt;{sender.email}&gt;</span>
)}
@@ -920,6 +934,14 @@ export function MvExpandedConversationCard({
</button>
)}
</div>
{email.isScheduled && (
<div className={cn("border-b border-border bg-primary/10 py-2 flex items-center gap-2 text-primary text-xs font-medium", compact ? "px-4" : "ps-[80px] pe-6")}>
<CalendarClock className="w-3.5 h-3.5 shrink-0" />
<span>
{t('scheduled_banner', { date: email.scheduledSendAt ? formatScheduledSendAt(email.scheduledSendAt, timeFormat) : '' })}
</span>
</div>
)}
{showDetails && <MvConversationDetails email={email} timeFormat={timeFormat} className={compact ? "px-4" : "ps-[80px] pe-6"} />}
{hasBody ? (
<ConversationEmailBody
+25 -1
View File
@@ -40,7 +40,9 @@ import {
FileArchive,
File,
Eye,
CalendarClock,
} from "@/components/icons";
import { formatScheduledSendAt } from "@/lib/scheduled-send-label";
import { useTranslations } from "next-intl";
import { useSettingsStore } from "@/stores/settings-store";
import { useContactStore } from "@/stores/contact-store";
@@ -360,6 +362,7 @@ function EmailCard({
}: EmailCardProps) {
const t = useTranslations();
const density = useSettingsStore((state) => 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')}
</span>
<ExternalSenderChip from={email.from} accountId={email.sourceClientAccountId} interactive={false} />
{email.isScheduled && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium bg-primary/10 text-primary shrink-0">
<CalendarClock className="w-3.5 h-3.5" />
{t('sidebar.scheduled')}
</span>
)}
{isStarred && (
<Star className="w-4 h-4 fill-amber-400 text-amber-400 flex-shrink-0" />
)}
@@ -410,7 +419,14 @@ function EmailCard({
)}
</div>
<div className="text-sm text-muted-foreground">
{formatDate(emailDisplayDate(email))}
{email.isScheduled && email.scheduledSendAt ? (
<span className="text-primary font-medium inline-flex items-center gap-1">
<CalendarClock className="w-3.5 h-3.5" />
{formatScheduledSendAt(email.scheduledSendAt, timeFormat)}
</span>
) : (
formatDate(emailDisplayDate(email))
)}
</div>
{!isExpanded && density !== 'extra-compact' && (
<p dir="auto" className="text-sm text-muted-foreground mt-1 line-clamp-2">
@@ -430,6 +446,14 @@ function EmailCard({
{/* Expanded Content */}
{isExpanded && (
<div className="border-t border-border animate-in slide-in-from-top-2 duration-200">
{email.isScheduled && (
<div className="px-4 py-2 bg-primary/10 border-b border-border flex items-center gap-2 text-primary text-xs font-medium">
<CalendarClock className="w-3.5 h-3.5 shrink-0" />
<span>
{t('email_viewer.scheduled_banner', { date: email.scheduledSendAt ? formatScheduledSendAt(email.scheduledSendAt, timeFormat) : '' })}
</span>
</div>
)}
<PluginSlot name="reader-below-subject" className="w-full" extraProps={plugin.emailProps} />
<PluginSlot name="email-banner" className="w-full" extraProps={plugin.emailProps} />
<ConversationEmailBody
+5 -1
View File
@@ -7,7 +7,7 @@ import { cleanPreview, formatDate } from "@/lib/utils";
import { Email } from "@/lib/jmap/types";
import { cn } from "@/lib/utils";
import { Avatar } from "@/components/ui/avatar";
import { Paperclip, Star, Circle, CheckSquare, Square, Reply, Forward } from "@/components/icons";
import { Paperclip, Star, Circle, CheckSquare, Square, Reply, Forward, CalendarClock } from "@/components/icons";
import { useEmailDrag } from "@/hooks/use-email-drag";
import { useLongPress } from "@/hooks/use-long-press";
import { useEmailStore } from "@/stores/email-store";
@@ -174,6 +174,7 @@ export function ThreadEmailItem({
</div>
</div>
<div className="flex shrink-0 items-center gap-2.5">
{email.isScheduled && <CalendarClock className="w-3.5 h-3.5 text-primary" />}
{isStarred && <Star className="w-3 h-3 fill-amber-400 text-amber-400" />}
{isAnswered && <Reply className="w-3 h-3 text-muted-foreground" />}
{isForwarded && <Forward className="w-3 h-3 text-muted-foreground" />}
@@ -278,6 +279,9 @@ export function ThreadEmailItem({
{/* Indicators */}
<div className="flex items-center gap-1 flex-shrink-0">
{email.isScheduled && (
<CalendarClock className="w-3 h-3 text-primary" />
)}
{isStarred && (
<Star className="w-3 h-3 fill-amber-400 text-amber-400" />
)}
@@ -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 <sender@example.com>',
'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 <fwd@example.com>',
'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);
});
});
+55 -17
View File
@@ -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<EmlAttachment>;
};
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 }) {
? `<!DOCTYPE html><html><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="${emailIframeCsp(blockRemote)}"><meta name="referrer" content="no-referrer"></head><body>${body}</body></html>`
: "";
const downloadAttachment = (att: NonNullable<ParsedEml["attachments"]>[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<ArrayBufferLike> vs ArrayBuffer).
@@ -103,19 +113,47 @@ export function EmlPreview({ message }: { message: ParsedEml }) {
<div className="mt-4 border-t border-border pt-3">
<div className="text-xs font-medium text-muted-foreground mb-2">{t("attachments")}</div>
<div className="flex flex-wrap gap-2">
{message.attachments.map((att, i) => (
<button
key={i}
type="button"
onClick={() => downloadAttachment(att)}
title={t("download")}
className="flex items-center gap-2 px-3 py-1.5 rounded-md text-sm bg-muted text-foreground hover:bg-muted/70"
>
<Paperclip className="w-3 h-3 flex-shrink-0" />
<span className="max-w-[200px] truncate">{att.filename ? stripBidiControls(att.filename) : "attachment"}</span>
<Download className="w-3 h-3 flex-shrink-0 text-muted-foreground" />
</button>
))}
{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 (
<button
key={i}
type="button"
onClick={() => downloadAttachment(att)}
title={t("download")}
className="flex items-center gap-2 px-3 py-1.5 rounded-md text-sm bg-muted text-foreground hover:bg-muted/70"
>
<Paperclip className="w-3 h-3 flex-shrink-0" />
<span className="max-w-[200px] truncate">{name}</span>
<Download className="w-3 h-3 flex-shrink-0 text-muted-foreground" />
</button>
);
}
return (
<div key={i} className="flex items-center rounded-md text-sm bg-muted text-foreground">
<button
type="button"
onClick={() => onOpenAttachment(att)}
title={tFiles("preview")}
className="flex items-center gap-2 ps-3 pe-2 py-1.5 rounded-s-md hover:bg-muted/70"
>
<Paperclip className="w-3 h-3 flex-shrink-0" />
<span className="max-w-[200px] truncate">{name}</span>
</button>
<button
type="button"
onClick={() => downloadAttachment(att)}
title={t("download")}
aria-label={`${t("download")} ${name}`}
className="ps-1 pe-3 py-1.5 rounded-e-md hover:bg-muted/70"
>
<Download className="w-3 h-3 text-muted-foreground" />
</button>
</div>
);
})}
</div>
</div>
)}
+49 -3
View File
@@ -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 <iframe> (desktop) or with the
// pdf.js canvas viewer (mobile). navigator.pdfViewerEnabled is the standard
@@ -266,6 +271,47 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent, fi
};
}, [getFileContent, name, blockRemote]);
const openEmlAttachment = (att: EmlAttachment) => {
if (!att.content) return;
setNestedPreview({
name: att.filename ? stripBidiControls(att.filename) : "attachment",
// content is a real ArrayBuffer/Uint8Array at runtime; cast for the
// strict BlobPart lib type, as EmlPreview's download does.
blob: new Blob([att.content as BlobPart], { type: att.mimeType || "application/octet-stream" }),
});
};
// Stable per attachment: the loader effect re-runs when it changes.
const nestedContent = useMemo(
() => nestedPreview
? async () => ({ blob: nestedPreview.blob, contentType: nestedPreview.blob.type })
: null,
[nestedPreview],
);
const downloadNested = () => {
if (!nestedPreview) return;
const url = URL.createObjectURL(nestedPreview.blob);
const a = document.createElement("a");
a.href = url;
a.download = nestedPreview.name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
// In place of this frame, not on top of it: one frame at a time takes
// Escape and the arrows. This one keeps its state and its Back entry.
if (nestedPreview && nestedContent) {
return (
<FilePreviewModal
name={nestedPreview.name}
onClose={() => setNestedPreview(null)}
onDownload={downloadNested}
getFileContent={nestedContent}
/>
);
}
return (
<PreviewShell
name={name}
@@ -306,7 +352,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent, fi
)}
{!loading && !error && fileType === "eml" && emlContent && (
<EmlPreview message={emlContent} />
<EmlPreview message={emlContent} onOpenAttachment={openEmlAttachment} />
)}
{!loading && !error && fileType === "image" && objectUrl && (
+82 -52
View File
@@ -37,7 +37,7 @@ import type { UnifiedAccountClient } from "@/lib/unified-mailbox";
import { connectedAccountsGrew } from "@/lib/unified-mailbox";
import { messagePriorityFromHeaders, type MessagePriority } from "@/lib/message-priority";
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
import { useEmailStore, pendingInitialData, buildUnifiedAccountClients, invalidateUnifiedMailboxes, captureViewToken, ArchiveMailboxNotFoundError, ensureArchiveMailbox, resolveSelectedEmails, resolveUnstampedEmailAccountId, resolveEmailBlobContext, resolveEmailActionContext, emailWriteSource, emailLinkSource, type EmailBlobContext, emptyFolderMovesToTrash, bulkDeleteIsPermanent, shouldConfirmBulkDelete, isAggregateListView, removalFolder, canRemoveFromFolder, restoreBatchesFor, restoreBatchesForSelection, TrashMailboxNotFoundError, type EmailRestoreBatch , type CrossAccountMoveReceipt } from "@/stores/email-store";
import { useEmailStore, annotateScheduledEmails, annotateScheduledEmail, pendingInitialData, buildUnifiedAccountClients, invalidateUnifiedMailboxes, captureViewToken, ArchiveMailboxNotFoundError, ensureArchiveMailbox, resolveSelectedEmails, resolveUnstampedEmailAccountId, resolveEmailBlobContext, resolveEmailActionContext, emailWriteSource, emailLinkSource, type EmailBlobContext, emptyFolderMovesToTrash, bulkDeleteIsPermanent, shouldConfirmBulkDelete, isAggregateListView, removalFolder, canRemoveFromFolder, restoreBatchesFor, restoreBatchesForSelection, TrashMailboxNotFoundError, type EmailRestoreBatch , type CrossAccountMoveReceipt } from "@/stores/email-store";
import { findDraftIdentityIdForLogin, listRowSource, resolveDraftSource, type DraftSource } from "@/lib/draft-source";
import { useRemoveFromFolder } from "@/hooks/use-remove-from-folder";
import { groupSearchScopeFolders, SEARCH_SCOPE_ALL_FOLDERS } from "@/lib/search-scope-folders";
@@ -2182,27 +2182,12 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}
}
if (result.scheduled) {
await refreshScheduledMetadata(client);
if (isScheduledView) await fetchScheduledEmails(client);
return;
}
// Delayed sends get the undo toast instead; a filing error or a refused
// recipient already warned.
if (!result.filingError && !result.rejectedRecipients?.length) toast.success(t('notifications.email_sent'));
// Refresh the current mailbox to update the UI
if (!isScheduledView) {
await refreshCurrentMailbox(client);
// Re-fetch the replied thread's cross-folder data so the expanded
// view shows the newly sent reply without collapsing.
if (originalEmailId) {
const emailState = useEmailStore.getState();
const repliedEmail = emailState.emails.find(e => e.id === originalEmailId);
if (repliedEmail?.threadId && emailState.expandedThreadIds.has(threadKeyFor(repliedEmail))) {
// Route to the email's own account so shared/group threads refresh
// from the right server, not the active one. (#281, #814)
const refreshRepliedThreadAfterSend = async (emailId: string | undefined) => {
if (!emailId || !client) return;
const emailState = useEmailStore.getState();
const repliedEmail = emailState.emails.find(e => e.id === emailId);
if (repliedEmail?.threadId && emailState.expandedThreadIds.has(threadKeyFor(repliedEmail))) {
try {
const route = resolveThreadRoute({
isUnifiedView: emailState.isUnifiedView,
ref: repliedEmail,
@@ -2223,8 +2208,31 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
return { threadEmailsCache: c };
});
}
} catch (e) {
debug.error('Failed to refresh replied thread after send:', e);
}
}
};
if (result.scheduled) {
await refreshScheduledMetadata(client);
if (isScheduledView) {
await fetchScheduledEmails(client);
} else {
await refreshCurrentMailbox(client);
}
await refreshRepliedThreadAfterSend(originalEmailId);
return;
}
// Delayed sends get the undo toast instead; a filing error or a refused
// recipient already warned.
if (!result.filingError && !result.rejectedRecipients?.length) toast.success(t('notifications.email_sent'));
// Refresh the current mailbox to update the UI
if (!isScheduledView) {
await refreshCurrentMailbox(client);
await refreshRepliedThreadAfterSend(originalEmailId);
}
} catch (error) {
console.error("Failed to send email:", error);
@@ -4017,8 +4025,46 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}
}
const refreshInlineRepliedThread = async () => {
const emailState = useEmailStore.getState();
const repliedEmail = emailState.emails.find(e => e.id === originalEmailId);
if (repliedEmail?.threadId && emailState.expandedThreadIds.has(threadKeyFor(repliedEmail))) {
const route = resolveThreadRoute({
isUnifiedView: emailState.isUnifiedView,
ref: repliedEmail,
mailboxes: viewMailboxes,
selectedMailbox: emailState.selectedMailbox,
});
const threadClient = route.clientAccountId
? (useAuthStore.getState().getClientForAccount(route.clientAccountId) ?? client)
: client;
try {
const fullEmails = await threadClient.getThreadEmails(
repliedEmail.threadId,
route.accountId ?? client.getAccountId(),
);
if (fullEmails.length > 0) {
useEmailStore.setState((state) => {
const c = new Map(state.threadEmailsCache);
c.set(threadKeyFor(repliedEmail), fullEmails);
return { threadEmailsCache: c };
});
}
} catch (e) {
debug.error('Failed to refresh replied thread after send:', e);
}
}
};
if (result.scheduled) {
await refreshScheduledMetadata(client);
// As below: a failed refresh must not reach the quick-reply box.
try {
await refreshCurrentMailbox(client);
await refreshInlineRepliedThread();
} catch (error) {
console.error('Quick reply scheduled, but refreshing the view failed:', error);
}
return true;
}
@@ -4030,34 +4076,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
try {
// Refresh emails to show the sent reply
await refreshCurrentMailbox(client);
// Re-fetch the replied thread's cross-folder data so the expanded
// view shows the newly sent reply without collapsing.
const emailState = useEmailStore.getState();
const repliedEmail = emailState.emails.find(e => e.id === originalEmailId);
if (repliedEmail?.threadId && emailState.expandedThreadIds.has(threadKeyFor(repliedEmail))) {
// Route to the email's own account so shared/group threads refresh from
// the right server, not the active one. (#281, #814)
const route = resolveThreadRoute({
isUnifiedView: emailState.isUnifiedView,
ref: repliedEmail,
mailboxes: viewMailboxes,
selectedMailbox: emailState.selectedMailbox,
});
const threadClient = route.clientAccountId
? (useAuthStore.getState().getClientForAccount(route.clientAccountId) ?? client)
: client;
const fullEmails = await threadClient.getThreadEmails(
repliedEmail.threadId,
route.accountId ?? client.getAccountId(),
);
if (fullEmails.length > 0) {
useEmailStore.setState((state) => {
const c = new Map(state.threadEmailsCache);
c.set(threadKeyFor(repliedEmail), fullEmails);
return { threadEmailsCache: c };
});
}
}
await refreshInlineRepliedThread();
} catch (error) {
console.error('Quick reply sent, but refreshing the view failed:', error);
}
@@ -4102,7 +4121,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
e.sourceClientAccountId = ref.sourceClientAccountId;
e.sourceAccountId = ref.sourceAccountId;
}
return emails
const scheduledSubmissionByEmailId = useEmailStore.getState().scheduledSubmissionByEmailId;
const annotated = annotateScheduledEmails(emails, scheduledSubmissionByEmailId);
return annotated
.filter((e) => e.id === ref.id || !e.keywords?.$draft)
.sort((a, b) => a.receivedAt.localeCompare(b.receivedAt));
})().catch(() => null);
@@ -4403,7 +4424,16 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
const fullEmail = await requestEmailContent(fetchClient, email.id, accountId);
if (openSeq !== emailOpenSeqRef.current) return;
if (fullEmail) {
if (listEmail?.isScheduled) {
const scheduledSubmissionByEmailId = useEmailStore.getState().scheduledSubmissionByEmailId;
const annotated = annotateScheduledEmail(fullEmail, scheduledSubmissionByEmailId);
if (annotated.isScheduled) {
fullEmail.scheduledSendAt = annotated.scheduledSendAt;
fullEmail.emailSubmissionId = annotated.emailSubmissionId;
fullEmail.scheduledIdentityId = annotated.scheduledIdentityId;
fullEmail.scheduledUndoStatus = annotated.scheduledUndoStatus;
fullEmail.scheduledAccountId = annotated.scheduledAccountId;
fullEmail.isScheduled = true;
} else if (listEmail?.isScheduled) {
fullEmail.scheduledSendAt = listEmail.scheduledSendAt;
fullEmail.emailSubmissionId = listEmail.emailSubmissionId;
fullEmail.scheduledIdentityId = listEmail.scheduledIdentityId;
@@ -0,0 +1,29 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { render } from '@testing-library/react';
const push = vi.hoisted(() => vi.fn());
vi.mock('@/i18n/navigation', () => ({ useRouter: () => ({ push }) }));
import { PluginPageNavigator } from '../plugin-page-navigator';
import { requestPageNavigation, resetPageNavigation } from '@/lib/plugin-sandbox/page-navigation';
import { resetPages, setPluginPages } from '@/lib/plugin-sandbox/pages';
afterEach(() => {
push.mockReset();
resetPages();
resetPageNavigation();
});
describe('PluginPageNavigator', () => {
it('moves the tab while it is mounted, and no longer after', () => {
setPluginPages('p1', 'Plugin One', [{ id: 'files', title: 'Files' }]);
const { unmount } = render(<PluginPageNavigator />);
expect(requestPageNavigation('p1', 'files', 'docs/a')).toBe(true);
expect(push).toHaveBeenCalledWith('/apps/p1/files/docs/a');
unmount();
expect(requestPageNavigation('p1', 'files', 'docs/a')).toBe(false);
expect(push).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,16 @@
'use client';
// Moves the tab when a plugin asks for one of its own pages (api.ui.openPage,
// lib/plugin-sandbox/page-navigation.ts). Mounted once in the localized
// layout, so the request works from every view; the locale-aware router
// keeps the user's locale.
import { useEffect } from 'react';
import { useRouter } from '@/i18n/navigation';
import { onPageNavigation } from '@/lib/plugin-sandbox/page-navigation';
export function PluginPageNavigator(): null {
const router = useRouter();
useEffect(() => onPageNavigation((href) => router.push(href as never)), [router]);
return null;
}
@@ -620,6 +620,63 @@ describe('JMAPClient resilience', () => {
});
});
// Each client sent a Core/echo every 30s for as long as the page was open,
// tabs in the background included, even while its push stream was proving
// every 30s that the server was there.
describe('keep-alive while the push stream is live', () => {
const encoder = new TextEncoder();
const isSSE = (init?: RequestInit) =>
(init?.headers as Record<string, string> | undefined)?.['Accept'] === 'text/event-stream';
const echoCount = () =>
fetchSpy.mock.calls.filter((call: unknown[]) => {
const body = (call[1] as RequestInit | undefined)?.body;
return typeof body === 'string' && body.includes('Core/echo');
}).length;
/** A stream the test feeds; any other request is answered by `api`. */
async function withPushStream(api: () => Promise<Response>) {
vi.useFakeTimers({ shouldAdvanceTime: false });
const client = await createConnectedClient();
let stream!: ReadableStreamDefaultController<Uint8Array>;
const body = new ReadableStream<Uint8Array>({ start(c) { stream = c; } });
fetchSpy.mockImplementation(((_url: RequestInfo | URL, init?: RequestInit) =>
isSSE(init) ? Promise.resolve(new Response(body, { status: 200 })) : api()) as never);
client.setupPushNotifications();
await vi.advanceTimersByTimeAsync(0);
const ping = () => stream.enqueue(encoder.encode('event: ping\ndata: {"interval":30}\n\n'));
return { client, ping };
}
const echo = () => Promise.resolve(mockFetchResponse(200, { methodResponses: [['Core/echo', { ping: 'pong' }, '0']] }));
it('sends no Core/echo while the stream keeps pinging', async () => {
const { ping } = await withPushStream(echo);
for (let i = 0; i < 4; i++) {
await vi.advanceTimersByTimeAsync(29_000);
ping();
await vi.advanceTimersByTimeAsync(1_000);
}
expect(echoCount()).toBe(0);
});
it('pings again, and reports the connection lost, once the stream goes quiet', async () => {
const { client, ping } = await withPushStream(() => Promise.reject(new TypeError('Failed to fetch')));
const callback = vi.fn();
client.onConnectionChange(callback);
ping();
await vi.advanceTimersByTimeAsync(30_000);
expect(echoCount()).toBe(0);
await vi.advanceTimersByTimeAsync(30_000);
await vi.advanceTimersByTimeAsync(1_000);
expect(echoCount()).toBeGreaterThan(0);
expect(callback).toHaveBeenCalledWith(false);
});
});
describe('disconnect', () => {
it('stops keep-alive and cleans up', async () => {
const client = await createConnectedClient();
@@ -287,3 +287,91 @@ describe('JMAPClient.discoverKeywords', () => {
expect(result.labels.map((label) => label.id)).toEqual(['$label:work', '$seen']);
});
});
// Two Email/query per tag is 240 method calls for 120 tags, re-run on every
// push. A server that already counts keywords answers all of it in one call.
describe('JMAPClient.getTagCounts with Keyword/get', () => {
let fetchSpy: ReturnType<typeof vi.spyOn>;
const tags = ['work', 'receipts', 'travel'];
beforeEach(() => {
fetchSpy = vi.spyOn(globalThis, 'fetch');
vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
fetchSpy.mockRestore();
vi.restoreAllMocks();
});
async function connect(session: unknown): Promise<JMAPClient> {
fetchSpy.mockResolvedValueOnce(jsonResponse(session));
const client = JMAPClient.withBearer('https://mail.example.com', 'token123', 'user@test.com');
await client.connect();
fetchSpy.mockReset();
return client;
}
function recordRequests(reply: (methodCalls: Array<[string, Record<string, unknown>, string]>, index: number) => unknown) {
const sent: Array<Array<[string, Record<string, unknown>, string]>> = [];
fetchSpy.mockImplementation((async (_url: string, init: RequestInit) => {
const body = JSON.parse(init.body as string);
const index = sent.length;
sent.push(body.methodCalls);
return jsonResponse(reply(body.methodCalls, index));
}) as never);
return sent;
}
const answerQueries = (methodCalls: Array<[string, Record<string, unknown>, string]>) => ({
methodResponses: methodCalls.map(([, , callId]) => ['Email/query', { total: 2 }, callId]),
});
it('reads every count from one Keyword/get when the server counts keywords', async () => {
const client = await connect(makeSession({}, true));
const sent = recordRequests(() => ({
methodResponses: [['Keyword/get', {
accountId: 'acct-1',
list: [
{ id: '$label:Work', name: 'Work', color: null, total: 12, unread: 5, isProviderLabel: true, source: 'provider' },
{ id: '$label:receipts', name: 'receipts', color: null, total: 3, unread: 0, isProviderLabel: true, source: 'provider' },
],
notFound: ['$label:travel'],
}, '0']],
}));
const counts = await client.getTagCounts(tags, undefined, ['trash', 'junk']);
expect(sent).toEqual([[['Keyword/get', { accountId: 'acct-1', ids: ['$label:work', '$label:receipts', '$label:travel'] }, '0']]]);
expect(counts).toEqual({
work: { total: 12, unread: 5 },
receipts: { total: 3, unread: 0 },
travel: { total: 0, unread: 0 },
});
});
it('falls back to querying when Keyword/get is refused', async () => {
const client = await connect(makeSession({}, true));
const sent = recordRequests((methodCalls, index) =>
index === 0
? { methodResponses: [['error', { type: 'unknownMethod' }, '0']] }
: answerQueries(methodCalls),
);
const counts = await client.getTagCounts(tags);
expect(sent[0].map(([name]) => name)).toEqual(['Keyword/get']);
expect(sent.slice(1).flat().every(([name]) => name === 'Email/query')).toBe(true);
expect(counts.travel).toEqual({ total: 2, unread: 2 });
});
it('keeps querying on a server that offers Keyword/get without counts', async () => {
const session = makeSession({}, true);
const client = await connect({ ...session, capabilities: { ...session.capabilities, [KEYWORDS_CAPABILITY]: {} } });
const sent = recordRequests(answerQueries);
await client.getTagCounts(tags);
expect(sent.flat().every(([name]) => name === 'Email/query')).toBe(true);
});
});
@@ -17,6 +17,7 @@ import { actionsFor, resetActions, runAction, checkActions } from '../plugin-san
import { dayLabelsByDate, resetDayLabels } from '../plugin-sandbox/day-labels';
import { allPages, resetPages } from '../plugin-sandbox/pages';
import { onPanelRequest, resetPanelRequests } from '../plugin-sandbox/panel-requests';
import { onPageNavigation, resetPageNavigation } from '../plugin-sandbox/page-navigation';
import type { StreamSink } from '../plugin-sandbox/proxy-client';
import '../plugin-sandbox/loader';
@@ -131,6 +132,43 @@ describe('day labels and pages', () => {
expect(allPages()).toEqual([expect.objectContaining({ pluginId: 'p1', id: 'files', title: 'Nextcloud' })]);
await expect(dispatchApiCall(plugin({ permissions: ['ui:app-page'] }), 'ui.setPages', [[{ id: 'A B', title: 'x' }]])).rejects.toThrow(/id/);
});
describe('ui.openPage', () => {
const paged = plugin({ permissions: ['ui:app-page'] });
const seen: string[] = [];
beforeEach(async () => {
resetPageNavigation();
seen.length = 0;
await dispatchApiCall(paged, 'ui.setPages', [[{ id: 'files', title: 'Nextcloud' }]]);
});
it('moves the tab to the plugin\'s own page, each path segment encoded once', async () => {
onPageNavigation((href) => { seen.push(href); });
await expect(dispatchApiCall(paged, 'ui.openPage', ['files'])).resolves.toBe(true);
await expect(dispatchApiCall(paged, 'ui.openPage', ['files', '/docs/a b/x?tab=1'])).resolves.toBe(true);
expect(seen).toEqual(['/apps/p1/files', '/apps/p1/files/docs/a%20b/x%3Ftab%3D1']);
});
it('needs ui:app-page and refuses another plugin\'s page or a page it did not set', async () => {
onPageNavigation((href) => { seen.push(href); });
await expect(dispatchApiCall(plugin(), 'ui.openPage', ['files'])).rejects.toThrow(/lacks permission/);
await expect(dispatchApiCall(plugin({ id: 'p2', permissions: ['ui:app-page'] }), 'ui.openPage', ['files'])).rejects.toThrow(/not a page of this plugin/);
await expect(dispatchApiCall(paged, 'ui.openPage', ['other'])).rejects.toThrow(/not a page of this plugin/);
expect(seen).toEqual([]);
});
it('refuses a path that would leave the page', async () => {
onPageNavigation((href) => { seen.push(href); });
await expect(dispatchApiCall(paged, 'ui.openPage', ['files', '../../settings'])).rejects.toThrow(/\.\./);
await expect(dispatchApiCall(paged, 'ui.openPage', ['files', 42])).rejects.toThrow(/string/);
await expect(dispatchApiCall(paged, 'ui.openPage', ['files', 'x'.repeat(2001)])).rejects.toThrow(/longer/);
expect(seen).toEqual([]);
});
it('answers false while nothing can move the tab', async () => {
await expect(dispatchApiCall(paged, 'ui.openPage', ['files'])).resolves.toBe(false);
});
});
});
describe('proxy.fetch', () => {
+58
View File
@@ -1911,6 +1911,10 @@ export class JMAPClient implements IJMAPClient {
this.pingSkipRemaining--;
return;
}
// A push stream that delivered something within its ping interval has
// just shown the server reachable; an echo would only repeat that. When
// the stream goes quiet or drops, the echo is back on the next tick.
if (this.pingFailureCount === 0 && this.isPushStreamLive()) return;
try {
await this.ping();
this.pingFailureCount = 0;
@@ -1937,6 +1941,13 @@ export class JMAPClient implements IJMAPClient {
}, 30_000);
}
/** Whether the SSE stream delivered an event or a ping within the last SSE_LIVE_WINDOW. */
private isPushStreamLive(): boolean {
return this.sseAbortController !== null
&& this.lastSSEActivity > 0
&& Date.now() - this.lastSSEActivity <= JMAPClient.SSE_LIVE_WINDOW;
}
private stopKeepAlive(): void {
if (this.pingInterval) {
clearInterval(this.pingInterval);
@@ -2589,6 +2600,10 @@ export class JMAPClient implements IJMAPClient {
if (tagIds.length === 0) return {};
const result: Record<string, { total: number; unread: number }> = {};
const targetAccountId = accountId || this.accountId;
if (this.countsKeywords(targetAccountId)) {
const counted = await this.getTagCountsFromKeywords(tagIds, targetAccountId);
if (counted) return counted;
}
const exclusion = excludeMailboxIds && excludeMailboxIds.length > 0
? [{ inMailboxOtherThan: excludeMailboxIds }]
: [];
@@ -2646,6 +2661,47 @@ export class JMAPClient implements IJMAPClient {
return result;
}
/** Whether the server reports every keyword's total and unread in Keyword/get. */
private countsKeywords(accountId: string): boolean {
const capability = this.capabilities[KEYWORDS_CAPABILITY] as { supportsCounts?: unknown } | undefined;
return capability?.supportsCounts === true && this.hasAccountCapability(KEYWORDS_CAPABILITY, accountId);
}
/**
* Tag counts read from the server's own keyword counters: one Keyword/get
* for every tag, where the query path costs two Email/query per tag.
*
* The counters cover the whole account, so `excludeMailboxIds` (#1156) is
* not applied: a server that counts keywords answers for its own notion of
* a tag's messages, and a gateway that maps tags to provider labels reports
* the numbers the provider shows for the label. Returns null when the call
* fails, so the caller can fall back to querying.
*/
private async getTagCountsFromKeywords(
tagIds: string[],
accountId: string,
): Promise<Record<string, { total: number; unread: number }> | null> {
try {
const response = await this.request(
[["Keyword/get", { accountId, ids: tagIds.map((id) => `$label:${id}`) }, "0"]],
["urn:ietf:params:jmap:core", KEYWORDS_CAPABILITY],
);
const [method, result] = response.methodResponses?.[0] ?? [];
if (method !== 'Keyword/get' || !Array.isArray(result?.list)) return null;
// Keywords are case-insensitive (RFC 8621 4.1.1).
const byId = new Map((result.list as KeywordInfo[]).map((k) => [k.id.toLowerCase(), k]));
const counts: Record<string, { total: number; unread: number }> = {};
for (const id of tagIds) {
const keyword = byId.get(`$label:${id}`.toLowerCase());
counts[id] = { total: keyword?.total ?? 0, unread: keyword?.unread ?? 0 };
}
return counts;
} catch (error) {
console.error('Failed to get tag counts from Keyword/get:', error);
return null;
}
}
/**
* Every keyword the account's messages actually carry, and how many messages
* carry each.
@@ -10314,6 +10370,8 @@ export class JMAPClient implements IJMAPClient {
private static readonly SECONDARY_POLL_INTERVAL = 20_000;
private static readonly SSE_RECONNECT_DELAY = 3_000;
private static readonly SSE_PING_TIMEOUT = 90_000; // 3x the 30s ping interval
// The 30s ping interval the stream is opened with, plus slack for a late one.
private static readonly SSE_LIVE_WINDOW = 45_000;
setupPushNotifications(): boolean {
const eventSourceUrl = this.getEventSourceUrl();
+4
View File
@@ -61,6 +61,7 @@ import { doNotificationsShow } from './host-api-notify';
import { doAccountDataGet, doAccountDataSet, doUserFindPeople, doUserGetMailAccounts, doUserGetSelf } from './host-api-account';
import { setSectionTitle } from './section-titles';
import { requestPanel } from './panel-requests';
import { requestPageNavigation } from './page-navigation';
import { doComposerAttach, doComposerGetBody, doComposerGetSelection, doComposerReplaceBody, doComposerReplaceSelection } from './host-api-composer';
import { pluginAllowed, wkdAllowed } from '../outgoing-hosts';
@@ -237,6 +238,8 @@ export const PERM_PER_METHOD: Readonly<Record<string, Permission | null>> = {
'ui.setListFilters': 'ui:message-list',
'ui.setDayLabels': 'ui:calendar-action',
'ui.setPages': 'ui:app-page',
// Move the tab to one of the plugin's own pages (never anywhere else).
'ui.openPage': 'ui:app-page',
'ui.setSectionTitle': 'ui:sidebar-widget',
// Open, close or toggle the panel the plugin's slot sits in; the panel's
// own permission is checked per call (see the slot catalogue).
@@ -2000,6 +2003,7 @@ export async function dispatchApiCall(
case 'ui.setListFilters': setPluginListFilters(plugin.id, plugin.name, checkListFilters(args[0])); return undefined;
case 'ui.setDayLabels': setDayLabels(plugin.id, plugin.name, checkDayLabels(args[0])); return undefined;
case 'ui.setPages': setPluginPages(plugin.id, plugin.name, checkPages(args[0])); return undefined;
case 'ui.openPage': return requestPageNavigation(plugin.id, args[0], args[1]);
case 'ui.setSectionTitle': setSectionTitle(plugin.id, args[0]); return undefined;
case 'ui.setPanel': {
const slot = args[0];
+51
View File
@@ -0,0 +1,51 @@
// A plugin moving the tab to one of its own pages (api.ui.openPage): a link
// in its dialog or banner to a record its page shows. Only the plugin's own
// pages, so a plugin can never send the tab anywhere else; the path is the
// one the `app-page` slot gets as `path`. The app's router answers through
// the handler PluginPageNavigator sets; without one (nothing mounted yet)
// the request resolves to false and the plugin can open a tab instead.
import { findPage } from './pages';
import { pluginPageHref } from '@/hooks/use-plugin-pages';
/** The longest path a plugin may pass. */
export const MAX_PAGE_PATH_LENGTH = 2000;
export type PageNavigationHandler = (href: string) => void;
let handler: PageNavigationHandler | null = null;
export function onPageNavigation(next: PageNavigationHandler): () => void {
handler = next;
return () => { if (handler === next) handler = null; };
}
/**
* The in-app href of a plugin's page and a path below it, each segment
* encoded once (the route decodes each once), or an error the plugin sees.
*/
export function pagePathHref(pluginId: string, pageId: unknown, path: unknown): string {
if (typeof pageId !== 'string' || !findPage(pluginId, pageId)) {
throw new Error(`ui.openPage: "${String(pageId)}" is not a page of this plugin`);
}
const href = pluginPageHref({ pluginId, id: pageId });
if (path === undefined || path === null || path === '') return href;
if (typeof path !== 'string') throw new Error('ui.openPage: path must be a string');
if (path.length > MAX_PAGE_PATH_LENGTH) throw new Error(`ui.openPage: path longer than ${MAX_PAGE_PATH_LENGTH} characters`);
const segments = path.replace(/^\/+/, '').split('/');
if (segments.some((s) => s === '.' || s === '..')) throw new Error('ui.openPage: path must not contain "." or ".." segments');
return `${href}/${segments.map(encodeURIComponent).join('/')}`;
}
/** Checks the request and moves the tab: true when it went, false when nothing could take it. */
export function requestPageNavigation(pluginId: string, pageId: unknown, path: unknown): boolean {
const href = pagePathHref(pluginId, pageId, path);
if (!handler) return false;
handler(href);
return true;
}
/** Test helper. */
export function resetPageNavigation(): void {
handler = null;
}
+9
View File
@@ -666,6 +666,15 @@ function buildPluginApi(manifest: PluginManifest) {
/** Full pages with a button in the navigation bar; the `app-page` slot renders them. */
setPages: (pages: Array<{ id: string; title: string; icon?: string }>) =>
callApi('ui.setPages', [pages]) as Promise<void>,
/**
* Moves this tab to one of this plugin's own pages (set with setPages),
* e.g. from a dialog's link to the record its page shows. `path` is
* what the `app-page` slot gets as `path` (segments joined by "/").
* Resolves to false when the app could not take it, so the plugin can
* open the page in a new tab with openExternalUrl instead.
*/
openPage: (pageId: string, path?: string) =>
callApi('ui.openPage', [pageId, path]) as Promise<boolean>,
/**
* The header of this plugin's sidebar section (the host draws it like
* Folders and Tags), e.g. a translated "Upcoming birthdays". null goes
+1 -1
View File
@@ -53,7 +53,7 @@ export const SLOT_CATALOG = {
'sidebar-section': S('navigation', 'frame', ['ui:sidebar-widget'], { mailboxId: null }, 'a collapsible section in the mail sidebar, below the folders'),
'sidebar-section-actions': S('navigation', 'frame', ['ui:sidebar-widget'], {}, 'compact controls beside a collapsible plugin sidebar-section heading', true),
'top-bar-actions': S('navigation', 'action', ['ui:toolbar'], {}, 'buttons in the top bar, next to search', true),
'app-page': S('navigation', 'frame', ['ui:app-page'], { pageId: null, path: null }, 'a full page of its own, with an entry in the navigation rail (api.ui.setPages)'),
'app-page': S('navigation', 'frame', ['ui:app-page'], { pageId: null, path: null }, 'a full page of its own, with an entry in the navigation rail (api.ui.setPages); api.ui.openPage moves the tab there'),
// ─── Message list ──────────────────────────────────────────
'message-list-header': S('mail-list', 'frame', ['ui:message-list'], { mailboxId: null, mailboxRole: null }, 'above the message list'),
@@ -0,0 +1,80 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useEmailStore } from '../email-store';
import { useAuthStore } from '../auth-store';
import { useSettingsStore } from '../settings-store';
import type { Mailbox, StateChange } from '@/lib/jmap/types';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
// Every open tab gets every push. A hidden tab used to recount every tag on
// each one - two queries per tag - for badges nobody was looking at.
const inbox = { id: 'inbox', role: 'inbox', isShared: false } as Mailbox;
function makeClient() {
return {
getEmails: vi.fn(async () => ({ emails: [], total: 0, hasMore: false, state: 'email-state' })),
getThreads: vi.fn(async () => []),
getTagCounts: vi.fn(async () => ({ work: { total: 1, unread: 1 } })),
getAccountId: () => 'account-a',
} as unknown as IJMAPClient & { getTagCounts: ReturnType<typeof vi.fn> };
}
const push = (state: string): StateChange => ({ '@type': 'StateChange', changed: { 'account-a': { Email: state } } });
let hidden = false;
function setHidden(value: boolean) {
hidden = value;
document.dispatchEvent(new Event('visibilitychange'));
}
describe('tag counts on pushes in a hidden tab', () => {
let client: ReturnType<typeof makeClient>;
beforeEach(() => {
Object.defineProperty(document, 'hidden', { configurable: true, get: () => hidden });
hidden = false;
client = makeClient();
useAuthStore.setState(useAuthStore.getInitialState());
useSettingsStore.setState({ emailKeywords: [{ id: 'work', label: 'Work', color: 'blue' }] } as never);
useEmailStore.setState({ ...useEmailStore.getInitialState(), selectedMailbox: 'inbox', mailboxes: [inbox] });
});
afterEach(() => {
hidden = false;
});
it('recounts on every push in a visible tab', async () => {
await useEmailStore.getState().handleStateChange(push('1'), client);
await useEmailStore.getState().handleStateChange(push('2'), client);
await vi.waitFor(() => expect(client.getTagCounts).toHaveBeenCalledTimes(2));
});
it('waits until the tab is shown, then recounts once', async () => {
setHidden(true);
await useEmailStore.getState().handleStateChange(push('1'), client);
await useEmailStore.getState().handleStateChange(push('2'), client);
expect(client.getTagCounts).not.toHaveBeenCalled();
setHidden(false);
await vi.waitFor(() => expect(client.getTagCounts).toHaveBeenCalledTimes(1));
setHidden(true);
setHidden(false);
await Promise.resolve();
expect(client.getTagCounts).toHaveBeenCalledTimes(1);
});
it("does not recount again for the client's replay of the same states on return", async () => {
setHidden(true);
await useEmailStore.getState().handleStateChange(push('2'), client);
setHidden(false);
await vi.waitFor(() => expect(client.getTagCounts).toHaveBeenCalledTimes(1));
await useEmailStore.getState().handleStateChange(push('2'), client);
expect(client.getTagCounts).toHaveBeenCalledTimes(1);
await useEmailStore.getState().handleStateChange(push('3'), client);
await vi.waitFor(() => expect(client.getTagCounts).toHaveBeenCalledTimes(2));
});
});
@@ -0,0 +1,109 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { useEmailStore, annotateScheduledEmail, annotateScheduledEmails } from '../email-store';
import type { Email } from '@/lib/jmap/types';
function makeEmail(id: string, threadId = `thread-${id}`): Email {
return {
id,
threadId,
mailboxIds: { inbox: true },
keywords: {},
size: 100,
receivedAt: new Date().toISOString(),
from: [{ name: 'Test', email: 'test@example.com' }],
to: [{ name: 'User', email: 'user@example.com' }],
subject: `Email ${id}`,
preview: 'preview',
hasAttachment: false,
textBody: [],
htmlBody: [],
bodyValues: {},
sourceClientAccountId: 'login-1',
sourceAccountId: 'acc-1',
};
}
describe('scheduled email annotation in email-store', () => {
// Keyed like the store's map: login, account, then the message id.
const metadataMap = new Map([
['login-1|acc-1|email-scheduled', {
submissionId: 'sub-1',
sendAt: '2026-10-30T10:00:00Z',
identityId: 'id-1',
undoStatus: 'pending' as const,
accountId: 'acc-1',
}],
]);
beforeEach(() => {
vi.restoreAllMocks();
useEmailStore.setState({
emails: [makeEmail('email-1'), makeEmail('email-scheduled')],
selectedEmail: null,
scheduledSubmissionByEmailId: metadataMap,
scheduledEmailIds: new Set(['email-scheduled']),
});
});
it('annotates email with scheduled properties when present in scheduledSubmissionByEmailId', () => {
const email = makeEmail('email-scheduled');
const annotated = annotateScheduledEmail(email, metadataMap);
expect(annotated.isScheduled).toBe(true);
expect(annotated.scheduledSendAt).toBe('2026-10-30T10:00:00Z');
expect(annotated.emailSubmissionId).toBe('sub-1');
expect(annotated.scheduledUndoStatus).toBe('pending');
expect(annotated.scheduledAccountId).toBe('acc-1');
});
it('returns identical reference when email is not scheduled and not in metadata', () => {
const email = makeEmail('email-1');
const annotated = annotateScheduledEmail(email, metadataMap);
expect(annotated).toBe(email);
expect(annotated.isScheduled).toBeUndefined();
});
it('removes scheduled properties when email is no longer in scheduled metadata', () => {
const previouslyScheduled: Email = {
...makeEmail('email-no-longer-scheduled'),
isScheduled: true,
scheduledSendAt: '2026-10-30T10:00:00Z',
emailSubmissionId: 'sub-old',
};
const cleaned = annotateScheduledEmail(previouslyScheduled, new Map());
expect(cleaned.isScheduled).toBeUndefined();
expect(cleaned.scheduledSendAt).toBeUndefined();
expect(cleaned.emailSubmissionId).toBeUndefined();
});
it('annotates a list of emails in annotateScheduledEmails', () => {
const emails = [makeEmail('email-1'), makeEmail('email-scheduled')];
const annotatedList = annotateScheduledEmails(emails, metadataMap);
expect(annotatedList[0].isScheduled).toBeUndefined();
expect(annotatedList[0]).toBe(emails[0]);
expect(annotatedList[1].isScheduled).toBe(true);
expect(annotatedList[1].scheduledSendAt).toBe('2026-10-30T10:00:00Z');
});
it('returns original array reference if scheduledSubmissionByEmailId is empty', () => {
const emails = [makeEmail('email-1')];
const result = annotateScheduledEmails(emails, new Map());
expect(result).toBe(emails);
});
it('preserves email object reference across selectEmail, beginOpenEmail, and finishOpenEmail', () => {
// Ensuring reference identity is preserved for plugin open/close hooks
const email = makeEmail('email-1');
useEmailStore.getState().selectEmail(email);
expect(useEmailStore.getState().selectedEmail).toBe(email);
useEmailStore.getState().beginOpenEmail(email);
expect(useEmailStore.getState().selectedEmail).toBe(email);
useEmailStore.getState().finishOpenEmail(email);
expect(useEmailStore.getState().selectedEmail).toBe(email);
});
});
+75 -4
View File
@@ -714,7 +714,7 @@ function getNextSelectedEmailAfterRemovingRow(
return null;
}
function annotateScheduledEmails(
export function annotateScheduledEmails(
emails: Email[],
scheduledSubmissionByEmailId: Map<string, ScheduledSubmissionMetadata>
): Email[] {
@@ -745,14 +745,26 @@ function scheduledSubmissionMap(client: IJMAPClient, emails: ScheduledEmail[]):
}]));
}
function annotateScheduledEmail(
export function annotateScheduledEmail(
email: Email,
scheduledSubmissionByEmailId: Map<string, ScheduledSubmissionMetadata>
): Email {
// The row's login and account: its stamps, else the folder on screen.
const { client, clientAccountId, accountId } = resolveEmailBlobContext(email, useAuthStore.getState().client);
const scheduled = scheduledSubmissionByEmailId.get(scheduledEmailKey(clientAccountId, accountId ?? client?.getAccountId(), email.id));
if (!scheduled) return email;
if (!scheduled) {
if (email.isScheduled) {
const copy = { ...email };
delete copy.isScheduled;
delete copy.scheduledSendAt;
delete copy.emailSubmissionId;
delete copy.scheduledIdentityId;
delete copy.scheduledUndoStatus;
delete copy.scheduledAccountId;
return copy;
}
return email;
}
return {
...email,
scheduledSendAt: scheduled.sendAt,
@@ -826,6 +838,64 @@ async function submissionMailboxes(
};
}
// ---------------------------------------------------------------------------
// Tag counts in hidden tabs
//
// Every open tab receives every push, and each recounted every tag on each
// one: two queries per tag, per account, per tab. A hidden tab shows no
// badge, so it only notes the Email states it was told about and recounts
// once when it is looked at again.
//
// On that return the client also re-checks the server's states and, when they
// moved since its last check, replays them as a push. The replay is skipped
// for tag counts when it carries exactly the states the return recount
// already covered.
// ---------------------------------------------------------------------------
type TagCountStates = { client: IJMAPClient; emailStates: Record<string, string> };
let tagCountsOnReturn: (TagCountStates & { get: () => EmailStore }) | null = null;
let tagCountsCoveredOnReturn: TagCountStates | null = null;
let tagCountsVisibilityListener = false;
function changedEmailStates(change: StateChange): Record<string, string> {
const states: Record<string, string> = {};
for (const [accountId, types] of Object.entries(change.changed)) {
if (types?.Email) states[accountId] = types.Email;
}
return states;
}
function recountTagCountsOnReturn(): void {
if (document.hidden || !tagCountsOnReturn) return;
const { client, emailStates, get } = tagCountsOnReturn;
tagCountsOnReturn = null;
tagCountsCoveredOnReturn = { client, emailStates };
void get().fetchTagCounts(client);
}
/** fetchTagCounts for a push, unless the tab is hidden or the push is the return's replay. */
function fetchTagCountsForPush(client: IJMAPClient, change: StateChange, get: () => EmailStore): void {
const emailStates = changedEmailStates(change);
if (typeof document !== 'undefined' && document.hidden) {
const pending = tagCountsOnReturn?.client === client ? tagCountsOnReturn.emailStates : {};
tagCountsOnReturn = { client, emailStates: { ...pending, ...emailStates }, get };
if (!tagCountsVisibilityListener) {
tagCountsVisibilityListener = true;
document.addEventListener('visibilitychange', recountTagCountsOnReturn);
}
return;
}
const covered = tagCountsCoveredOnReturn;
tagCountsCoveredOnReturn = null;
tagCountsOnReturn = null;
if (
covered?.client === client &&
Object.entries(emailStates).every(([accountId, state]) => covered.emailStates[accountId] === state)
) {
return;
}
void get().fetchTagCounts(client);
}
function coalesceRefresh(client: IJMAPClient, key: string, run: () => Promise<void>): Promise<void> {
let byKey = inFlightRefreshes.get(client);
if (!byKey) {
@@ -6587,7 +6657,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
await get().refreshCurrentMailbox(client);
}
if (anyEmailChanged) {
get().fetchTagCounts(client);
fetchTagCountsForPush(client, change, get);
}
}
@@ -7894,6 +7964,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
scheduledEmails: result.emails,
scheduledEmailIds,
scheduledSubmissionByEmailId,
emails: annotateScheduledEmails(get().emails, scheduledSubmissionByEmailId),
scheduledTotal: result.total,
scheduledTotalByAccount: result.totalByAccount ?? {},
scheduledHasMore: result.hasMore,