diff --git a/benchmarks/README.md b/benchmarks/README.md new file mode 100644 index 0000000..434bf16 --- /dev/null +++ b/benchmarks/README.md @@ -0,0 +1,31 @@ +# Performance baseline + +Run the repeatable local rendering benchmark with: + +```sh +pnpm benchmark +``` + +It uses the three checked-in real-message fixtures and measures both the +synchronous HTML preparation path and dark-theme iframe startup. The iframe +number is a comparative JSDOM measurement, not browser wall-clock time. + +## July 23, 2026 + +Environment: Node 25.9.0 on the same local development machine. + +| Path | Before | After | Change | +| --- | ---: | ---: | ---: | +| `prepareHtml` per email | 0.1935 ms | 0.1977 ms | effectively unchanged | +| Dark iframe boot per email | 1,932.95 ms | 210.62 ms | 89.1% faster (9.2×) | +| Initial visible mailbox data | 325.4 ms | 308.7 ms | 5.1% faster | + +The mailbox timing is the median of three read-only requests to the same +Fastmail account, excluding session and mailbox discovery. Before the change, +five independent list requests had to finish before the panel could render. +After the change, one inbox request renders the visible list first; drafts, +sent, spam, and pinned mail stream in afterward (about 147 ms in the measured +run) without blocking the page shell or message pane. + +Network timings naturally vary. Use several samples and compare medians rather +than treating a single run as definitive. diff --git a/package.json b/package.json index 2def2ce..7bd4595 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "start": "next start", "lint": "eslint", "test": "tsx --test 'src/lib/__tests__/*.test.ts'", + "benchmark": "tsx scripts/benchmark-rendering.ts", "fetch-fixtures": "tsx scripts/fetch-fixtures.ts" }, "dependencies": { diff --git a/scripts/benchmark-rendering.ts b/scripts/benchmark-rendering.ts new file mode 100644 index 0000000..37a7dfa --- /dev/null +++ b/scripts/benchmark-rendering.ts @@ -0,0 +1,152 @@ +import { readFileSync } from "node:fs"; +import { performance } from "node:perf_hooks"; +import path from "node:path"; +import { JSDOM, VirtualConsole } from "jsdom"; +import { prepareHtml } from "../src/lib/emailHtml"; + +const FIXTURE_NAMES = [ + "new-dental-appointment-for-phillip_Stp0bVMUG5Sc.json", + "start-small-and-scale-when-it-matters_Stp09-UTL1Lw.json", + "plan-your-weekend-10-open-houses-for-sale-near-bellevue-wa-9_Stp09-_Y2kmN.json", +]; +const PREPARE_ROUNDS = 1_000; +const IFRAME_ROUNDS = 2; + +interface Fixture { + bodyValues: Record; + htmlBody: Array<{ partId?: string | null }>; +} + +function fixtureHtml(name: string): string { + const fixturePath = path.resolve( + process.cwd(), + "src/lib/__tests__/fixtures", + name, + ); + const fixture = JSON.parse(readFileSync(fixturePath, "utf8")) as Fixture; + const partId = fixture.htmlBody.find((part) => part.partId)?.partId; + if (!partId || !fixture.bodyValues[partId]) { + throw new Error(`Fixture ${name} has no HTML body`); + } + return fixture.bodyValues[partId].value; +} + +function median(values: number[]): number { + const sorted = [...values].sort((a, b) => a - b); + const middle = Math.floor(sorted.length / 2); + return sorted.length % 2 + ? sorted[middle] + : (sorted[middle - 1] + sorted[middle]) / 2; +} + +const fixtures = FIXTURE_NAMES.map((name) => ({ + name, + html: fixtureHtml(name), +})); + +// Warm up the regular-expression and string-replacement paths before timing. +for (const fixture of fixtures) prepareHtml(fixture.html); + +const prepareStartedAt = performance.now(); +for (let round = 0; round < PREPARE_ROUNDS; round++) { + for (const fixture of fixtures) prepareHtml(fixture.html); +} +const prepareDuration = performance.now() - prepareStartedAt; +const prepareOperations = PREPARE_ROUNDS * fixtures.length; + +async function benchmarkIframeBoot(html: string): Promise { + const virtualConsole = new VirtualConsole(); + const startedAt = performance.now(); + const dom = new JSDOM(prepareHtml(html), { + runScripts: "dangerously", + pretendToBeVisual: true, + virtualConsole, + beforeParse(window) { + Object.defineProperty(window, "matchMedia", { + configurable: true, + value: () => ({ + matches: true, + media: "(prefers-color-scheme:dark)", + onchange: null, + addListener() {}, + removeListener() {}, + addEventListener() {}, + removeEventListener() {}, + dispatchEvent() { + return true; + }, + }), + }); + window.postMessage = (() => {}) as typeof window.postMessage; + window.requestAnimationFrame = ((callback: FrameRequestCallback) => { + callback(0); + return 1; + }) as typeof window.requestAnimationFrame; + Object.defineProperty(window, "MutationObserver", { + configurable: true, + value: undefined, + }); + Object.defineProperty(window, "ResizeObserver", { + configurable: true, + value: undefined, + }); + for (const property of [ + "scrollHeight", + "offsetHeight", + "scrollWidth", + "offsetWidth", + ]) { + Object.defineProperty(window.HTMLElement.prototype, property, { + configurable: true, + get: () => 640, + }); + } + }, + }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + const duration = performance.now() - startedAt; + dom.window.close(); + return duration; +} + +async function main() { + const iframeDurations: number[] = []; + for (let round = 0; round < IFRAME_ROUNDS; round++) { + for (const fixture of fixtures) { + iframeDurations.push(await benchmarkIframeBoot(fixture.html)); + } + } + + const iframeDuration = iframeDurations.reduce( + (sum, value) => sum + value, + 0, + ); + console.log("Email rendering benchmark"); + console.log(`Node ${process.version} · ${fixtures.length} real-message fixtures`); + console.table([ + { + benchmark: "prepareHtml", + samples: prepareOperations, + statistic: "mean", + "total ms": prepareDuration.toFixed(2), + "ms/op": (prepareDuration / prepareOperations).toFixed(4), + "ops/sec": Math.round((prepareOperations / prepareDuration) * 1_000), + }, + { + benchmark: "dark iframe boot (JSDOM)", + samples: iframeDurations.length, + statistic: "median", + "total ms": iframeDuration.toFixed(2), + "ms/op": median(iframeDurations).toFixed(2), + "ops/sec": Math.round( + (iframeDurations.length / iframeDuration) * 1_000, + ), + }, + ]); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/src/app/(inbox)/attachment/spreadsheet/page.tsx b/src/app/(inbox)/attachment/spreadsheet/page.tsx index 44622e2..bcc1b5d 100644 --- a/src/app/(inbox)/attachment/spreadsheet/page.tsx +++ b/src/app/(inbox)/attachment/spreadsheet/page.tsx @@ -2,8 +2,6 @@ import MobileBackButton from "@/components/MobileBackButton"; import SpreadsheetViewer from "@/components/SpreadsheetViewer"; import { notFound } from "next/navigation"; -export const dynamic = "force-dynamic"; - interface Props { searchParams: Promise<{ blobId?: string; diff --git a/src/app/(inbox)/calendar/page.tsx b/src/app/(inbox)/calendar/page.tsx index f14cafd..04de8f1 100644 --- a/src/app/(inbox)/calendar/page.tsx +++ b/src/app/(inbox)/calendar/page.tsx @@ -5,9 +5,8 @@ import CalendarEventLink from "@/components/CalendarEventLink"; import MobileCalendarAgenda from "@/components/MobileCalendarAgenda"; import { resolveCalendarEvent } from "@/lib/calendarDetect"; import { addMonths, buildCalendarEntries, buildMonthDays, filterEventsForMonth, monthTitle, normalizeMonthKey } from "@/lib/calendarView"; -import { getAccountId, getMailboxes, getSession, listRecentCalendarCandidateEmails } from "@/lib/jmap"; - -export const dynamic = "force-dynamic"; +import { listRecentCalendarCandidateEmails } from "@/lib/jmap"; +import { getJmapMailboxContext } from "@/lib/jmapServer"; const DAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const; @@ -19,9 +18,7 @@ export default async function CalendarPage({ searchParams }: Props) { const { month } = await searchParams; const monthKey = normalizeMonthKey(month); - const session = await getSession(); - const accountId = getAccountId(session); - const mailboxes = await getMailboxes(session.apiUrl, accountId); + const { session, accountId, mailboxes } = await getJmapMailboxContext(); const excludedMailboxIds = new Set( mailboxes diff --git a/src/app/(inbox)/email/[id]/loading.tsx b/src/app/(inbox)/email/[id]/loading.tsx index 2a473de..5313e0e 100644 --- a/src/app/(inbox)/email/[id]/loading.tsx +++ b/src/app/(inbox)/email/[id]/loading.tsx @@ -1,27 +1,5 @@ +import { MessageLoadingSkeleton } from "@/components/LoadingSkeletons"; + export default function EmailLoading() { - return ( -
-
-
-
-
-
-
-
-
-
-
-
-
- {Array.from({ length: 6 }).map((_, i) => ( -
- ))} -
-
-
- ); + return ; } diff --git a/src/app/(inbox)/email/[id]/page.tsx b/src/app/(inbox)/email/[id]/page.tsx index a64b381..89d6066 100644 --- a/src/app/(inbox)/email/[id]/page.tsx +++ b/src/app/(inbox)/email/[id]/page.tsx @@ -1,9 +1,8 @@ -import { getSession, getAccountId, getEmail } from "@/lib/jmap"; +import { getEmail } from "@/lib/jmap"; import { notFound } from "next/navigation"; import EmailDetailView from "@/components/EmailDetailView"; import MobileBackButton from "@/components/MobileBackButton"; - -export const dynamic = "force-dynamic"; +import { getJmapContext } from "@/lib/jmapServer"; interface Props { params: Promise<{ id: string }>; @@ -16,8 +15,7 @@ export default async function EmailPage({ params, searchParams }: Props) { const from = resolvedSearchParams.from; const backLabel = from === "spam" ? "Spam" : from === "sent" ? "Sent" : "Inbox"; - const session = await getSession(); - const accountId = getAccountId(session); + const { session, accountId } = await getJmapContext(); const email = await getEmail(session.apiUrl, accountId, id); if (!email) return notFound(); diff --git a/src/app/(inbox)/layout.tsx b/src/app/(inbox)/layout.tsx index e501e77..95690e5 100644 --- a/src/app/(inbox)/layout.tsx +++ b/src/app/(inbox)/layout.tsx @@ -1,28 +1,42 @@ -/* eslint-disable react-hooks/purity */ import { Suspense } from "react"; -import { getSession, getAccountId, getMailboxes, listInboxEmails, listDrafts, listPinnedEmails, listSentEmails } from "@/lib/jmap"; +import { unstable_rethrow } from "next/navigation"; +import { loadMailPanelData } from "@/lib/jmap"; import { log } from "@/lib/logger"; -import EmailListPanel from "@/components/EmailListPanel"; +import EmailListPanel, { + DeferredMailPanelSync, + type DeferredMailPanelData, +} from "@/components/EmailListPanel"; import InboxPanelLayout from "@/components/InboxPanelLayout"; +import { MailListLoadingSkeleton } from "@/components/LoadingSkeletons"; +import { getJmapMailboxContext } from "@/lib/jmapServer"; -export const dynamic = "force-dynamic"; +const EMPTY_DEFERRED_DATA: DeferredMailPanelData = { + drafts: [], + sentEmails: [], + pinnedEmails: [], + spamUnreads: [], + spamUnreadTotal: 0, + spamReads: [], + spamReadTotal: 0, +}; -export default async function InboxLayout({ - children, +async function DeferredPanelData({ + result, }: { - children: React.ReactNode; + result: Promise; }) { - const t = Date.now(); + return ; +} - let session, accountId, mailboxes; +async function MailPanelData() { + let context: Awaited>; try { - session = await getSession(); - accountId = getAccountId(session); - mailboxes = await getMailboxes(session.apiUrl, accountId); + context = await getJmapMailboxContext(); } catch (err) { - log.error({ err, duration_ms: Date.now() - t }, "layout.inbox.session_error"); + unstable_rethrow(err); + log.error({ err }, "layout.inbox.session_error"); return ( -
+

Unable to connect to mail server @@ -34,6 +48,7 @@ export default async function InboxLayout({

); } + const { session, accountId, mailboxes } = context; const inbox = mailboxes.find((m) => m.role === "inbox"); const draftsMailbox = mailboxes.find((m) => m.role === "drafts"); @@ -42,75 +57,101 @@ export default async function InboxLayout({ const trashMailbox = mailboxes.find((m) => m.role === "trash"); const spamMailbox = mailboxes.find((m) => m.role === "junk" || m.name.toLowerCase() === "spam" || m.name.toLowerCase() === "junk"); - let inbox_emails = { unreads: [] as Awaited>["unreads"], unreadTotal: 0, reads: [] as Awaited>["reads"], readTotal: 0 }; - let drafts: Awaited> = []; - let pinned: Awaited> = []; - let sentResult: Awaited> = { emails: [], total: 0 }; - let spam_emails = { unreads: [] as Awaited>["unreads"], unreadTotal: 0, reads: [] as Awaited>["reads"], readTotal: 0 }; + const primaryResult = loadMailPanelData( + session.apiUrl, + accountId, + { inbox: inbox?.id }, + false, + ); + const deferredResult = primaryResult + .then(() => + loadMailPanelData( + session.apiUrl, + accountId, + { + drafts: draftsMailbox?.id, + sent: sentMailbox?.id, + spam: spamMailbox?.id, + }, + true, + ), + ) + .then( + (data): DeferredMailPanelData => ({ + drafts: data.drafts, + sentEmails: data.sent.emails, + pinnedEmails: data.pinned, + spamUnreads: data.spam.unreads, + spamUnreadTotal: data.spam.unreadTotal, + spamReads: data.spam.reads, + spamReadTotal: data.spam.readTotal, + }), + ) + .catch((err) => { + unstable_rethrow(err); + log.error({ err }, "layout.inbox.deferred_error"); + return EMPTY_DEFERRED_DATA; + }); + + let panelData: Awaited>; try { - [inbox_emails, drafts, pinned, sentResult, spam_emails] = await Promise.all([ - inbox - ? listInboxEmails(session.apiUrl, accountId, inbox.id) - : Promise.resolve({ unreads: [], unreadTotal: 0, reads: [], readTotal: 0 }), - draftsMailbox - ? listDrafts(session.apiUrl, accountId, draftsMailbox.id) - : Promise.resolve([]), - listPinnedEmails(session.apiUrl, accountId), - sentMailbox - ? listSentEmails(session.apiUrl, accountId, sentMailbox.id) - : Promise.resolve({ emails: [], total: 0 }), - spamMailbox - ? listInboxEmails(session.apiUrl, accountId, spamMailbox.id) - : Promise.resolve({ unreads: [], unreadTotal: 0, reads: [], readTotal: 0 }), - ]); + panelData = await primaryResult; } catch (err) { - log.error({ err, duration_ms: Date.now() - t }, "layout.inbox.fetch_error"); - // Fall through with empty data — the panel will render with a refresh prompt + unstable_rethrow(err); + log.error({ err }, "layout.inbox.fetch_error"); + panelData = { + inbox: { unreads: [], unreadTotal: 0, reads: [], readTotal: 0 }, + drafts: [], + pinned: [], + sent: { emails: [], total: 0 }, + spam: { unreads: [], unreadTotal: 0, reads: [], readTotal: 0 }, + }; } - const { unreads, unreadTotal, reads, readTotal } = inbox_emails; + const { unreads, unreadTotal, reads, readTotal } = panelData.inbox; log.info({ unread_count: unreads.length, unread_total: unreadTotal, read_count: reads.length, read_total: readTotal, - draft_count: drafts.length, - pinned_count: pinned.length, - sent_count: sentResult.emails.length, - sent_total: sentResult.total, - spam_unread_count: spam_emails.unreads.length, - spam_unread_total: spam_emails.unreadTotal, - spam_read_count: spam_emails.reads.length, - spam_read_total: spam_emails.readTotal, + pinned_count: panelData.pinned.length, has_drafts_mailbox: !!draftsMailbox, has_sent_mailbox: !!sentMailbox, has_spam_mailbox: !!spamMailbox, - duration_ms: Date.now() - t, }, "layout.inbox.load"); + return ( + + + + } + /> + ); +} + +export default function InboxLayout({ + children, +}: { + children: React.ReactNode; +}) { return ( - + }> + } > diff --git a/src/app/(inbox)/settings/page.tsx b/src/app/(inbox)/settings/page.tsx index 73e4ea1..d2c15a9 100644 --- a/src/app/(inbox)/settings/page.tsx +++ b/src/app/(inbox)/settings/page.tsx @@ -1,12 +1,10 @@ -import { getSession, getAccountId, getIdentities } from "@/lib/jmap"; +import { getIdentities } from "@/lib/jmap"; import SignatureForm from "./SignatureForm"; import MobileBackButton from "@/components/MobileBackButton"; - -export const dynamic = "force-dynamic"; +import { getJmapContext } from "@/lib/jmapServer"; export default async function SettingsPage() { - const session = await getSession(); - const accountId = getAccountId(session); + const { session, accountId } = await getJmapContext(); const identities = await getIdentities(session.apiUrl, accountId); const identity = identities.find((i) => i.mayDelete === false) ?? identities[0]; diff --git a/src/app/(inbox)/thread/[threadId]/loading.tsx b/src/app/(inbox)/thread/[threadId]/loading.tsx index 6c4e8a2..cd8d2d1 100644 --- a/src/app/(inbox)/thread/[threadId]/loading.tsx +++ b/src/app/(inbox)/thread/[threadId]/loading.tsx @@ -1,25 +1,5 @@ -export default function Loading() { - return ( -
-
-
-
- {[1, 2, 3].map((i) => ( -
-
-
-
-
-
-
-
-
- ))} -
-
-
- ); +import { MessageLoadingSkeleton } from "@/components/LoadingSkeletons"; + +export default function ThreadLoading() { + return ; } diff --git a/src/app/(inbox)/thread/[threadId]/page.tsx b/src/app/(inbox)/thread/[threadId]/page.tsx index 683a3f7..aa54514 100644 --- a/src/app/(inbox)/thread/[threadId]/page.tsx +++ b/src/app/(inbox)/thread/[threadId]/page.tsx @@ -1,4 +1,4 @@ -import { getSession, getAccountId, getThreadEmails, getMailboxes } from "@/lib/jmap"; +import { getThreadEmails } from "@/lib/jmap"; import { notFound } from "next/navigation"; import Link from "next/link"; import MobileBackButton from "@/components/MobileBackButton"; @@ -6,8 +6,10 @@ import EmailDetailView from "@/components/EmailDetailView"; import ThreadCountBadge from "@/components/ThreadCountBadge"; import ThreadView from "@/components/ThreadView"; import { resolveCalendarEvent } from "@/lib/calendarDetect"; - -export const dynamic = "force-dynamic"; +import { + getJmapContext, + getJmapMailboxContext, +} from "@/lib/jmapServer"; interface Props { params: Promise<{ threadId: string }>; @@ -20,8 +22,7 @@ export default async function ThreadPage({ params, searchParams }: Props) { const from = resolvedSearchParams.from; const backLabel = from === "spam" ? "Spam" : from === "sent" ? "Sent" : "Inbox"; - const session = await getSession(); - const accountId = getAccountId(session); + const { session, accountId } = await getJmapContext(); const emails = await getThreadEmails(session.apiUrl, accountId, threadId); if (!emails.length) return notFound(); @@ -46,14 +47,17 @@ export default async function ThreadPage({ params, searchParams }: Props) { const subject = emails[emails.length - 1].subject ?? "(no subject)"; - const mailboxes = await getMailboxes(session.apiUrl, accountId); + const [{ mailboxes }, calendarEvents] = await Promise.all([ + getJmapMailboxContext(), + Promise.all( + emails.map((email) => + resolveCalendarEvent(email, session.downloadUrl, accountId), + ), + ), + ]); const spamMailbox = mailboxes.find((m) => m.role === "junk" || m.name.toLowerCase() === "spam" || m.name.toLowerCase() === "junk"); const inboxMailbox = mailboxes.find((m) => m.role === "inbox"); - const calendarEvents = await Promise.all( - emails.map((e) => resolveCalendarEvent(e, session.downloadUrl, accountId)) - ); - return (
diff --git a/src/app/compose/page.tsx b/src/app/compose/page.tsx index be92160..c10aee9 100644 --- a/src/app/compose/page.tsx +++ b/src/app/compose/page.tsx @@ -1,4 +1,4 @@ -import { getSession, getAccountId, getIdentities, getEmail } from "@/lib/jmap"; +import { getIdentities, getEmail } from "@/lib/jmap"; import { formatAddressRFC, formatFullDate } from "@/lib/format"; import { reSubject, @@ -11,8 +11,7 @@ import { } from "@/lib/compose"; import Composer from "@/components/Composer"; import MobileBackButton from "@/components/MobileBackButton"; - -export const dynamic = "force-dynamic"; +import { getJmapContext } from "@/lib/jmapServer"; interface Props { searchParams: Promise<{ mode?: string; id?: string; draftId?: string }>; @@ -21,9 +20,18 @@ interface Props { export default async function ComposePage({ searchParams }: Props) { const { mode, id, draftId } = await searchParams; - const session = await getSession(); - const accountId = getAccountId(session); - const identities = await getIdentities(session.apiUrl, accountId); + const { session, accountId } = await getJmapContext(); + const sourceEmailId = + draftId ?? + (id && (mode === "reply" || mode === "reply-all" || mode === "forward") + ? id + : undefined); + const [identities, sourceEmail] = await Promise.all([ + getIdentities(session.apiUrl, accountId), + sourceEmailId + ? getEmail(session.apiUrl, accountId, sourceEmailId) + : Promise.resolve(null), + ]); const sorted = identities.sort((a, b) => { if (a.mayDelete === false && b.mayDelete !== false) return -1; @@ -60,7 +68,7 @@ export default async function ComposePage({ searchParams }: Props) { // Resume a saved draft if (draftId) { - const draft = await getEmail(session.apiUrl, accountId, draftId); + const draft = sourceEmail; if (draft) { initialDraftId = draftId; initialTo = draft.to?.map(formatAddressRFC).join(", ") ?? ""; @@ -83,7 +91,7 @@ export default async function ComposePage({ searchParams }: Props) { } } } else if (id && (mode === "reply" || mode === "reply-all" || mode === "forward")) { - const email = await getEmail(session.apiUrl, accountId, id); + const email = sourceEmail; if (email) { let bodyText = ""; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 076a737..c634d90 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,11 +1,14 @@ import type { Metadata } from "next"; import Link from "next/link"; +import { unstable_rethrow } from "next/navigation"; +import { Suspense } from "react"; import { auth, signOut } from "@/auth"; import LiveUnreadCountBadge from "@/components/LiveUnreadCountBadge"; import MobileNav from "@/components/MobileNav"; -import UnreadCountBadge from "@/components/UnreadCountBadge"; -import UnreadCountProvider from "@/components/UnreadCountProvider"; -import { getAccountId, getMailboxes, getSession as getJmapSession } from "@/lib/jmap"; +import UnreadCountProvider, { + MailboxCountSync, +} from "@/components/UnreadCountProvider"; +import { getJmapMailboxContext } from "@/lib/jmapServer"; import "./globals.css"; export const metadata: Metadata = { @@ -13,32 +16,49 @@ export const metadata: Metadata = { description: "Personal email client", }; +async function MailboxCountsLoader() { + try { + const { mailboxes } = await getJmapMailboxContext(); + const inbox = mailboxes.find((mailbox) => mailbox.role === "inbox"); + const drafts = mailboxes.find((mailbox) => mailbox.role === "drafts"); + const spam = mailboxes.find( + (mailbox) => + mailbox.role === "junk" || + mailbox.name.toLowerCase() === "spam" || + mailbox.name.toLowerCase() === "junk", + ); + + return ( + + ); + } catch (error) { + unstable_rethrow(error); + return null; + } +} + export default async function RootLayout({ children, }: { children: React.ReactNode; }) { const session = await auth(); - let unreadTotal = 0; - let draftTotal = 0; - let spamUnreadTotal = 0; - - if (session) { - const jmapSession = await getJmapSession(); - const accountId = getAccountId(jmapSession); - const mailboxes = await getMailboxes(jmapSession.apiUrl, accountId); - const inboxMailbox = mailboxes.find((mailbox) => mailbox.role === "inbox"); - const draftsMailbox = mailboxes.find((mailbox) => mailbox.role === "drafts"); - const spamMailbox = mailboxes.find((mailbox) => mailbox.role === "junk" || mailbox.name.toLowerCase() === "spam" || mailbox.name.toLowerCase() === "junk"); - unreadTotal = inboxMailbox?.unreadEmails ?? 0; - draftTotal = draftsMailbox?.totalEmails ?? 0; - spamUnreadTotal = spamMailbox?.unreadEmails ?? 0; - } return ( - + + {session && ( + + + + )} {/* Row: desktop sidebar + main content */}
{/* Sidebar — desktop only */} @@ -60,7 +80,7 @@ export default async function RootLayout({ className="flex items-center justify-between gap-2 rounded-md text-sm text-stone-600 dark:text-stone-300 hover:text-stone-900 dark:hover:text-white hover:bg-stone-100 dark:hover:bg-stone-800 px-2.5 py-2 transition-colors" > Drafts - + Spam - + {/* Mobile bottom nav — hidden on desktop */} -
+
diff --git a/src/app/print/[id]/page.tsx b/src/app/print/[id]/page.tsx index 1d151eb..483b3c9 100644 --- a/src/app/print/[id]/page.tsx +++ b/src/app/print/[id]/page.tsx @@ -1,10 +1,9 @@ -import { getSession, getAccountId, getEmail } from "@/lib/jmap"; +import { getEmail } from "@/lib/jmap"; import { formatAddressList, formatFullDate } from "@/lib/format"; import { notFound } from "next/navigation"; import PrintControls from "@/components/PrintControls"; import { resolvePrintBody } from "@/lib/printHtml"; - -export const dynamic = "force-dynamic"; +import { getJmapContext } from "@/lib/jmapServer"; interface Props { params: Promise<{ id: string }>; @@ -12,8 +11,7 @@ interface Props { export default async function PrintPage({ params }: Props) { const { id } = await params; - const session = await getSession(); - const accountId = getAccountId(session); + const { session, accountId } = await getJmapContext(); const email = await getEmail(session.apiUrl, accountId, id); if (!email) return notFound(); diff --git a/src/app/print/thread/[threadId]/page.tsx b/src/app/print/thread/[threadId]/page.tsx index c5ffe8b..e4b1b43 100644 --- a/src/app/print/thread/[threadId]/page.tsx +++ b/src/app/print/thread/[threadId]/page.tsx @@ -1,10 +1,9 @@ -import { getSession, getAccountId, getThreadEmails } from "@/lib/jmap"; +import { getThreadEmails } from "@/lib/jmap"; import { formatAddressList, formatFullDate } from "@/lib/format"; import { notFound } from "next/navigation"; import PrintControls from "@/components/PrintControls"; import { resolvePrintBody } from "@/lib/printHtml"; - -export const dynamic = "force-dynamic"; +import { getJmapContext } from "@/lib/jmapServer"; interface Props { params: Promise<{ threadId: string }>; @@ -12,8 +11,7 @@ interface Props { export default async function PrintThreadPage({ params }: Props) { const { threadId } = await params; - const session = await getSession(); - const accountId = getAccountId(session); + const { session, accountId } = await getJmapContext(); const emails = await getThreadEmails(session.apiUrl, accountId, threadId); if (!emails.length) return notFound(); diff --git a/src/components/EmailBody.tsx b/src/components/EmailBody.tsx index 8e7c693..3e94674 100644 --- a/src/components/EmailBody.tsx +++ b/src/components/EmailBody.tsx @@ -1,11 +1,13 @@ "use client"; -import { useCallback, useEffect, useRef } from "react"; +import { useCallback, useEffect, useMemo, useRef } from "react"; import useBodyClass from "@/components/useBodyClass"; import { lockEmailContentWidth } from "@/lib/emailFrameLayout"; import { prepareHtml, prepareTextBody } from "@/lib/emailHtml"; import type { EmailBodyPart } from "@/lib/types"; +const EMPTY_EMBEDDED_PARTS: EmailBodyPart[] = []; + interface Props { body: string; type: "html" | "text"; @@ -17,7 +19,7 @@ export default function EmailBody({ body, type, stripQuotes, - embeddedParts = [], + embeddedParts = EMPTY_EMBEDDED_PARTS, }: Props) { const wrapperRef = useRef(null); const iframeRef = useRef(null); @@ -123,10 +125,13 @@ export default function EmailBody({ }; }, [syncIframeLayout]); - const srcDoc = - type === "html" - ? prepareHtml(body, { stripQuotes, embeddedParts }) - : prepareTextBody(body, { stripQuotes }); + const srcDoc = useMemo( + () => + type === "html" + ? prepareHtml(body, { stripQuotes, embeddedParts }) + : prepareTextBody(body, { stripQuotes }), + [body, embeddedParts, stripQuotes, type], + ); return (
m.role === "junk" || diff --git a/src/components/EmailListPanel.tsx b/src/components/EmailListPanel.tsx index 312fc4a..42ac3d6 100644 --- a/src/components/EmailListPanel.tsx +++ b/src/components/EmailListPanel.tsx @@ -1,8 +1,19 @@ "use client"; import Link from "next/link"; -import { useEffect, useMemo, useRef, useState, useTransition } from "react"; +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + useTransition, + type ReactNode, +} from "react"; import SenderAvatar from "@/components/SenderAvatar"; +import { MailRowsLoadingSkeleton } from "@/components/LoadingSkeletons"; import { useUnreadCount } from "@/components/UnreadCountProvider"; import UnreadCountBadge from "@/components/UnreadCountBadge"; import ThreadCountBadge from "@/components/ThreadCountBadge"; @@ -30,10 +41,37 @@ interface Props { spamReads?: Email[]; spamReadTotal?: number; spamMailboxId?: string; + deferredContent?: ReactNode; } type View = "inbox" | "drafts" | "sent" | "spam"; +export interface DeferredMailPanelData { + drafts: Email[]; + sentEmails: Email[]; + pinnedEmails: Email[]; + spamUnreads: Email[]; + spamUnreadTotal: number; + spamReads: Email[]; + spamReadTotal: number; +} + +const DeferredMailPanelContext = createContext< + ((data: DeferredMailPanelData) => void) | null +>(null); + +export function DeferredMailPanelSync({ + data, +}: { + data: DeferredMailPanelData; +}) { + const sync = useContext(DeferredMailPanelContext); + useEffect(() => { + sync?.(data); + }, [data, sync]); + return null; +} + // --------------------------------------------------------------------------- // Inline SVG icons // --------------------------------------------------------------------------- @@ -121,16 +159,17 @@ export default function EmailListPanel({ reads, readTotal, inboxId, - drafts = [], - sentEmails = [], + drafts: initialDrafts = [], + sentEmails: initialSentEmails = [], pinnedEmails = [], archiveMailboxId, trashMailboxId, - spamUnreads = [], - spamUnreadTotal = 0, - spamReads = [], - spamReadTotal = 0, + spamUnreads: initialSpamUnreads = [], + spamUnreadTotal: initialSpamUnreadTotal = 0, + spamReads: initialSpamReads = [], + spamReadTotal: initialSpamReadTotal = 0, spamMailboxId, + deferredContent, }: Props) { const pathname = usePathname(); const router = useRouter(); @@ -191,11 +230,36 @@ export default function EmailListPanel({ const [extraUnreads, setExtraUnreads] = useState([]); const [extraReads, setExtraReads] = useState([]); const [loadingMore, setLoadingMore] = useState(false); + const [draftsList, setDraftsList] = useState(initialDrafts); + const [pinnedList, setPinnedList] = useState(pinnedEmails); + + const [deferredPending, setDeferredPending] = useState(!!deferredContent); + const [sentList, setSentList] = useState(initialSentEmails); + const [spamData, setSpamData] = useState({ + unreads: initialSpamUnreads, + unreadTotal: initialSpamUnreadTotal, + reads: initialSpamReads, + readTotal: initialSpamReadTotal, + }); + + const syncDeferredData = useCallback((data: DeferredMailPanelData) => { + setDraftsList(data.drafts); + setSentList(data.sentEmails); + setPinnedList(data.pinnedEmails); + setSpamData({ + unreads: data.spamUnreads, + unreadTotal: data.spamUnreadTotal, + reads: data.spamReads, + readTotal: data.spamReadTotal, + }); + setDeferredPending(false); + }, []); - const currentUnreads = view === "spam" ? spamUnreads : unreads; - const currentUnreadTotal = view === "spam" ? spamUnreadTotal : unreadTotal; - const currentReads = view === "spam" ? spamReads : reads; - const currentReadTotal = view === "spam" ? spamReadTotal : readTotal; + const currentUnreads = view === "spam" ? spamData.unreads : unreads; + const currentUnreadTotal = + view === "spam" ? spamData.unreadTotal : unreadTotal; + const currentReads = view === "spam" ? spamData.reads : reads; + const currentReadTotal = view === "spam" ? spamData.readTotal : readTotal; const currentMailboxId = view === "spam" ? spamMailboxId ?? "" : inboxId; useEffect(() => { @@ -204,18 +268,12 @@ export default function EmailListPanel({ }, [view]); useEffect(() => { - const fresh = [...currentUnreads, ...currentReads, ...pinnedEmails]; + const fresh = [...currentUnreads, ...currentReads, ...pinnedList]; setExtraUnreads((prev) => mergeEmailUpdates(prev, fresh)); setExtraReads((prev) => mergeEmailUpdates(prev, fresh)); // Server state is authoritative after a refresh; clear optimistic removals setArchivedIds(new Set()); - }, [currentUnreads, currentReads, pinnedEmails]); - - // ------------------------------------------------------------------------- - // Drafts — local copy for optimistic deletion - // ------------------------------------------------------------------------- - const [draftsList, setDraftsList] = useState(drafts); - useEffect(() => { setDraftsList(drafts); }, [drafts]); + }, [currentUnreads, currentReads, pinnedList]); // ------------------------------------------------------------------------- // Selection state @@ -295,17 +353,17 @@ export default function EmailListPanel({ // Display order: pinned → unread (not pinned) → read (not pinned), deduped const allInboxEmails = useMemo(() => { - const pinnedIds = new Set(pinnedEmails.map((e) => e.id)); + const pinnedIds = new Set(pinnedList.map((e) => e.id)); const seenIds = new Set(); const result: Email[] = []; const add = (e: Email) => { if (!seenIds.has(e.id)) { seenIds.add(e.id); result.push(e); } }; if (view === "inbox") { - pinnedEmails.forEach(add); + pinnedList.forEach(add); } allUnreads.filter((e) => !pinnedIds.has(e.id)).forEach(add); allReads.filter((e) => !pinnedIds.has(e.id)).forEach(add); return result; - }, [pinnedEmails, allUnreads, allReads, view]); + }, [pinnedList, allUnreads, allReads, view]); const visibleEmails = useMemo(() => { const base = isInSearchMode ? searchResults : allInboxEmails; @@ -507,7 +565,9 @@ export default function EmailListPanel({ // Render // ------------------------------------------------------------------------- return ( -
+ + {deferredContent} +
{/* Header */}
@@ -686,15 +746,21 @@ export default function EmailListPanel({ )}
- {isSearching && ( + {view === "spam" && deferredPending && ( + + )} + {(!deferredPending || view !== "spam") && isSearching && (

Searching…

)} - {!isSearching && visibleEmails.length === 0 && ( + {(!deferredPending || view !== "spam") && + !isSearching && + visibleEmails.length === 0 && (

{isInSearchMode ? "No results." : "No emails."}

)} - {!isSearching && + {(!deferredPending || view !== "spam") && + !isSearching && visibleThreads.map((thread, idx) => { const { latestEmail, senders } = thread; const threadHref = view === "spam" ? `/thread/${thread.threadId}?from=spam` : `/thread/${thread.threadId}`; @@ -881,7 +947,7 @@ export default function EmailListPanel({ ); })} - {hasMore && ( + {(!deferredPending || view !== "spam") && hasMore && (
+ ); } diff --git a/src/components/LiveUnreadCountBadge.tsx b/src/components/LiveUnreadCountBadge.tsx index 4299a74..b8511c0 100644 --- a/src/components/LiveUnreadCountBadge.tsx +++ b/src/components/LiveUnreadCountBadge.tsx @@ -1,17 +1,22 @@ "use client"; import UnreadCountBadge from "@/components/UnreadCountBadge"; -import { useUnreadCount } from "@/components/UnreadCountProvider"; +import { + type MailboxCounts, + useMailboxCounts, +} from "@/components/UnreadCountProvider"; interface Props { + mailbox?: keyof MailboxCounts; showZero?: boolean; className?: string; } export default function LiveUnreadCountBadge({ + mailbox = "inbox", showZero = false, className = "", }: Props) { - const count = useUnreadCount(); + const count = useMailboxCounts()[mailbox]; return ; } diff --git a/src/components/LoadingSkeletons.tsx b/src/components/LoadingSkeletons.tsx new file mode 100644 index 0000000..f09eddb --- /dev/null +++ b/src/components/LoadingSkeletons.tsx @@ -0,0 +1,117 @@ +import type { CSSProperties } from "react"; + +function SkeletonBar({ + className, + style, +}: { + className: string; + style?: CSSProperties; +}) { + return ( +
+ ); +} + +export function MailListLoadingSkeleton() { + return ( +
+
+ + +
+
+ +
+
+ +
+
+ ); +} + +export function MailRowsLoadingSkeleton({ count = 7 }: { count?: number }) { + return ( +
+ {Array.from({ length: count }).map((_, index) => ( +
+
+
+
+ + +
+ + +
+
+ ))} +
+ ); +} + +export function MessageLoadingSkeleton() { + return ( +
+
+
+ + +
+ + + + + + +
+ +
+ {[64, 104, 58, 72, 62].map((width) => ( + + ))} +
+ +
+
+
+
+ + +
+
+ + + +
+ + +
+
+
+
+ ); +} diff --git a/src/components/MobileNav.tsx b/src/components/MobileNav.tsx index c8bd146..5619a26 100644 --- a/src/components/MobileNav.tsx +++ b/src/components/MobileNav.tsx @@ -2,7 +2,7 @@ import Link from "next/link"; import { usePathname, useSearchParams } from "next/navigation"; -import { useUnreadCount } from "@/components/UnreadCountProvider"; +import { useMailboxCounts } from "@/components/UnreadCountProvider"; import UnreadCountBadge from "@/components/UnreadCountBadge"; function InboxIcon() { @@ -68,16 +68,11 @@ function SpamIcon() { ); } -interface Props { - draftTotal?: number; - spamTotal?: number; -} - -export default function MobileNav({ draftTotal = 0, spamTotal = 0 }: Props) { +export default function MobileNav() { const pathname = usePathname(); const searchParams = useSearchParams(); const from = searchParams.get("from"); - const unreadTotal = useUnreadCount(); + const counts = useMailboxCounts(); const tabs = [ { @@ -91,14 +86,14 @@ export default function MobileNav({ draftTotal = 0, spamTotal = 0 }: Props) { pathname.startsWith("/attachment/")) && from !== "spam" && from !== "sent", - badge: unreadTotal, + badge: counts.inbox, }, { href: "/drafts", label: "Drafts", icon: , active: pathname.startsWith("/drafts"), - badge: draftTotal, + badge: counts.drafts, }, { href: "/sent", @@ -112,7 +107,7 @@ export default function MobileNav({ draftTotal = 0, spamTotal = 0 }: Props) { label: "Spam", icon: , active: pathname.startsWith("/spam") || from === "spam", - badge: spamTotal, + badge: counts.spam, }, { href: "/calendar", diff --git a/src/components/UnreadCountProvider.tsx b/src/components/UnreadCountProvider.tsx index a135448..f955a2b 100644 --- a/src/components/UnreadCountProvider.tsx +++ b/src/components/UnreadCountProvider.tsx @@ -1,31 +1,70 @@ "use client"; -import { createContext, useContext, useEffect, useMemo, useState } from "react"; +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from "react"; import { applyUnreadCountChange, unreadCountEvents } from "@/lib/unreadCount"; -const UnreadCountContext = createContext(0); +export interface MailboxCounts { + inbox: number; + drafts: number; + spam: number; +} + +interface MailboxCountContextValue { + counts: MailboxCounts; + syncCounts: (counts: MailboxCounts) => void; +} + +const EMPTY_COUNTS: MailboxCounts = { inbox: 0, drafts: 0, spam: 0 }; +const MailboxCountContext = createContext({ + counts: EMPTY_COUNTS, + syncCounts: () => undefined, +}); interface Props { - initialCount: number; + initialCounts?: MailboxCounts; children: React.ReactNode; } -export default function UnreadCountProvider({ initialCount, children }: Props) { - const [count, setCount] = useState(initialCount); +export default function UnreadCountProvider({ + initialCounts = EMPTY_COUNTS, + children, +}: Props) { + const [counts, setCounts] = useState(initialCounts); useEffect(() => { - setCount(initialCount); - }, [initialCount]); + setCounts(initialCounts); + }, [initialCounts]); useEffect(() => { function onMarkRead(event: Event) { const detail = (event as CustomEvent<{ ids?: string[] }>).detail; - setCount((current) => applyUnreadCountChange(current, "read", detail?.ids ?? [])); + setCounts((current) => ({ + ...current, + inbox: applyUnreadCountChange( + current.inbox, + "read", + detail?.ids ?? [], + ), + })); } function onMarkUnread(event: Event) { const detail = (event as CustomEvent<{ ids?: string[] }>).detail; - setCount((current) => applyUnreadCountChange(current, "unread", detail?.ids ?? [])); + setCounts((current) => ({ + ...current, + inbox: applyUnreadCountChange( + current.inbox, + "unread", + detail?.ids ?? [], + ), + })); } window.addEventListener(unreadCountEvents.markRead, onMarkRead); @@ -37,15 +76,35 @@ export default function UnreadCountProvider({ initialCount, children }: Props) { }; }, []); - const value = useMemo(() => count, [count]); + const syncCounts = useCallback((nextCounts: MailboxCounts) => { + setCounts(nextCounts); + }, []); + const value = useMemo( + () => ({ counts, syncCounts }), + [counts, syncCounts], + ); return ( - + {children} - + ); } export function useUnreadCount(): number { - return useContext(UnreadCountContext); + return useContext(MailboxCountContext).counts.inbox; +} + +export function useMailboxCounts(): MailboxCounts { + return useContext(MailboxCountContext).counts; +} + +export function MailboxCountSync({ counts }: { counts: MailboxCounts }) { + const { syncCounts } = useContext(MailboxCountContext); + + useEffect(() => { + syncCounts(counts); + }, [counts, syncCounts]); + + return null; } diff --git a/src/lib/__tests__/jmap.test.ts b/src/lib/__tests__/jmap.test.ts index cd9b519..b358f42 100644 --- a/src/lib/__tests__/jmap.test.ts +++ b/src/lib/__tests__/jmap.test.ts @@ -3,7 +3,7 @@ process.env.FASTMAIL_API_TOKEN = "test-token"; import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { clearRecipientSuggestionCaches, deleteDraft, getAccountId, getContactsAccountId, getUnreadInboxTotal, listInboxEmails, loadMoreEmailsFiltered, moveEmailsToMailbox, parseAddresses, saveDraft, searchContacts, searchRecipientSuggestions, sendEmail, setKeywordsOnMany } from "../jmap"; +import { buildMailPanelMethodCalls, clearRecipientSuggestionCaches, deleteDraft, getAccountId, getContactsAccountId, getUnreadInboxTotal, listInboxEmails, loadMailPanelData, loadMoreEmailsFiltered, moveEmailsToMailbox, parseAddresses, saveDraft, searchContacts, searchRecipientSuggestions, sendEmail, setKeywordsOnMany } from "../jmap"; const MAIL_CAP = "urn:ietf:params:jmap:mail"; @@ -313,6 +313,73 @@ describe("listInboxEmails", () => { }); }); +describe("loadMailPanelData", () => { + it("keeps secondary panel queries off the visible inbox critical path", () => { + const primaryCalls = buildMailPanelMethodCalls( + "acct1", + { inbox: "inbox" }, + false, + ); + const deferredCalls = buildMailPanelMethodCalls("acct1", { + drafts: "drafts", + sent: "sent", + spam: "spam", + }); + + assert.equal(primaryCalls.length, 4); + assert.equal(deferredCalls.length, 10); + assert.ok( + primaryCalls.every(([, , callId]) => callId !== "pq" && callId !== "pg"), + ); + }); + + it("loads all persistent mail-panel views in one JMAP request", async () => { + capturedBodies = []; + mockResponses = [ + makeJmapResponse([ + ["Email/query", { total: 2 }, "iuq"], + ["Email/get", { list: [makeEmailResponse("unread", false)] }, "iug"], + ["Email/query", { total: 7 }, "irq"], + ["Email/get", { list: [makeEmailResponse("read", true)] }, "irg"], + ["Email/query", { total: 1 }, "dq"], + ["Email/get", { list: [makeEmailResponse("draft", true)] }, "dg"], + ["Email/query", { total: 1 }, "pq"], + ["Email/get", { list: [makeEmailResponse("pinned", true)] }, "pg"], + ["Email/query", { total: 9 }, "sq"], + ["Email/get", { list: [makeEmailResponse("sent", true)] }, "sg"], + ["Email/query", { total: 3 }, "suq"], + ["Email/get", { list: [makeEmailResponse("spam-unread", false)] }, "sug"], + ["Email/query", { total: 4 }, "srq"], + ["Email/get", { list: [makeEmailResponse("spam-read", true)] }, "srg"], + ]), + ]; + + const result = await loadMailPanelData( + "https://api.example.com/jmap", + "acct1", + { + inbox: "inbox", + drafts: "drafts", + sent: "sent", + spam: "spam", + }, + ); + + assert.equal(capturedBodies.length, 1); + assert.equal( + (capturedBodies[0] as { methodCalls: unknown[] }).methodCalls.length, + 14, + ); + assert.equal(result.inbox.unreadTotal, 2); + assert.equal(result.inbox.readTotal, 7); + assert.equal(result.drafts[0].id, "draft"); + assert.equal(result.pinned[0].id, "pinned"); + assert.equal(result.sent.total, 9); + assert.equal(result.spam.unreadTotal, 3); + assert.equal(result.spam.readTotal, 4); + }); +}); + // --------------------------------------------------------------------------- // loadMoreEmailsFiltered // --------------------------------------------------------------------------- diff --git a/src/lib/emailHtml.ts b/src/lib/emailHtml.ts index ec44eef..e39eac7 100644 --- a/src/lib/emailHtml.ts +++ b/src/lib/emailHtml.ts @@ -177,14 +177,14 @@ function darkTextAdaptationScript(theme: EmailRenderTheme): string { } return false; } - function effectiveBackground(element){ - for(var current=element;current;current=current.parentElement){ - var style=window.getComputedStyle(current); - if(style.backgroundImage&&style.backgroundImage!=='none')return null; - var colour=parseRgb(style.backgroundColor); - if(colour&&colour.a>0.01)return colour; - } - return parseRgb(${fallbackBackground}); + function effectiveBackground(element,style,backgrounds){ + if(style.backgroundImage&&style.backgroundImage!=='none')return null; + var colour=parseRgb(style.backgroundColor); + if(colour&&colour.a>0.01)return colour; + var parent=element.parentElement; + return parent&&backgrounds.has(parent) + ?backgrounds.get(parent) + :parseRgb(${fallbackBackground}); } function needsAdaptation(foreground,background){ if(!foreground||!background)return false; @@ -193,7 +193,9 @@ function darkTextAdaptationScript(theme: EmailRenderTheme): string { if(spread>56||luminance(foreground)>0.18)return false; return contrastRatio(foreground,background)<4.5; } + var darkTextAdapted=false; function adaptDarkText(){ + darkTextAdapted=true; var marked=document.querySelectorAll( '[data-email-client-adapted-text],[data-email-client-adapted-marker]' ); @@ -206,12 +208,14 @@ function darkTextAdaptationScript(theme: EmailRenderTheme): string { var elements=[document.body]; var descendants=document.body.getElementsByTagName('*'); for(var j=0;j, + limit = 50, +): MethodCall[] { + const queryId = `${callId}q`; + const getId = `${callId}g`; + return [ + [ + "Email/query", + { + accountId, + filter: { inMailbox: mailboxId, ...filter }, + sort: [{ property: "receivedAt", isAscending: false }], + calculateTotal: true, + limit, + position: 0, + }, + queryId, + ], + [ + "Email/get", + { + accountId, + "#ids": { + resultOf: queryId, + name: "Email/query", + path: "/ids", + }, + properties: EMAIL_LIST_PROPERTIES, + }, + getId, + ], + ]; +} + +export function buildMailPanelMethodCalls( + accountId: string, + mailboxIds: MailPanelMailboxIds, + includePinned = true, +): MethodCall[] { + const calls: MethodCall[] = []; + + if (mailboxIds.inbox) { + calls.push( + ...mailboxQuery(accountId, mailboxIds.inbox, "iu", { + notKeyword: "$seen", + }), + ...mailboxQuery(accountId, mailboxIds.inbox, "ir", { + hasKeyword: "$seen", + }), + ); + } + if (mailboxIds.drafts) { + calls.push( + ...mailboxQuery(accountId, mailboxIds.drafts, "d", {}), + ); + } + + if (includePinned) { + calls.push( + [ + "Email/query", + { + accountId, + filter: { hasKeyword: "$flagged" }, + sort: [{ property: "receivedAt", isAscending: false }], + limit: 100, + position: 0, + }, + "pq", + ], + [ + "Email/get", + { + accountId, + "#ids": { + resultOf: "pq", + name: "Email/query", + path: "/ids", + }, + properties: EMAIL_LIST_PROPERTIES, + }, + "pg", + ], + ); + } + + if (mailboxIds.sent) { + calls.push(...mailboxQuery(accountId, mailboxIds.sent, "s", {})); + } + if (mailboxIds.spam) { + calls.push( + ...mailboxQuery(accountId, mailboxIds.spam, "su", { + notKeyword: "$seen", + }), + ...mailboxQuery(accountId, mailboxIds.spam, "sr", { + hasKeyword: "$seen", + }), + ); + } + + return calls; +} + +/** + * Load the requested persistent-panel buckets in one JMAP request. JMAP + * back-references keep each query/get pair server-side. Callers can omit + * pinned mail to keep the visible inbox request on the critical path small. + */ +export async function loadMailPanelData( + apiUrl: string, + accountId: string, + mailboxIds: MailPanelMailboxIds, + includePinned = true, +): Promise { + const startedAt = Date.now(); + const methodCalls = buildMailPanelMethodCalls( + accountId, + mailboxIds, + includePinned, + ); + const data = await jmapCall(apiUrl, methodCalls); + const byCallId = new Map( + data.methodResponses.map(([, result, callId]) => [callId, result]), + ); + const list = (callId: string) => + (byCallId.get(callId)?.list as Email[] | undefined) ?? []; + const total = (callId: string) => + (byCallId.get(callId)?.total as number | undefined) ?? 0; + + const result: MailPanelData = { + inbox: { + unreads: list("iug"), + unreadTotal: total("iuq"), + reads: list("irg"), + readTotal: total("irq"), + }, + drafts: list("dg"), + pinned: list("pg"), + sent: { + emails: list("sg"), + total: total("sq"), + }, + spam: { + unreads: list("sug"), + unreadTotal: total("suq"), + reads: list("srg"), + readTotal: total("srq"), + }, + }; + + log.info( + { + method_count: methodCalls.length, + duration_ms: Date.now() - startedAt, + }, + "jmap.mail_panel", + ); + return result; +} + // HTML is usually compact because image payloads are separate MIME parts, but // complex newsletters can exceed the previous 1 MB cap and were silently // returned as truncated bodyValues. Keep a generous bounded cap so full diff --git a/src/lib/jmapServer.ts b/src/lib/jmapServer.ts new file mode 100644 index 0000000..9518605 --- /dev/null +++ b/src/lib/jmapServer.ts @@ -0,0 +1,20 @@ +import { cache } from "react"; +import { + getAccountId, + getMailboxes, + getSession, +} from "@/lib/jmap"; + +export const getJmapContext = cache(async () => { + const session = await getSession(); + return { + session, + accountId: getAccountId(session), + }; +}); + +export const getJmapMailboxContext = cache(async () => { + const { session, accountId } = await getJmapContext(); + const mailboxes = await getMailboxes(session.apiUrl, accountId); + return { session, accountId, mailboxes }; +});