mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 00:14:23 +00:00
Merge branch 'main' of https://github.com/bulwarkmail/webmail
This commit is contained in:
@@ -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' }));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"><{sender.email}></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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user