From 677c64bf72dac68a8d93f97118c00019d159ac35 Mon Sep 17 00:00:00 2001 From: Phillip Carter Date: Mon, 27 Jul 2026 20:14:49 -0700 Subject: [PATCH] Refactor and P2 improvements --- src/app/(inbox)/actions.ts | 89 ++-- src/app/(inbox)/email/[id]/actions.ts | 18 +- src/app/(inbox)/layout.tsx | 93 ++-- src/app/(inbox)/thread/[threadId]/page.tsx | 11 +- src/app/api/send/route.ts | 3 +- src/app/compose/actions.ts | 3 +- src/app/layout.tsx | 10 +- src/app/smoke-tests/SmokeHarness.tsx | 70 ++- src/components/DesktopNav.tsx | 42 +- src/components/EmailDetailView.tsx | 18 +- src/components/EmailListPanel.tsx | 578 ++++++++++----------- src/components/MessageActionBar.tsx | 129 +++-- src/components/MobileNav.tsx | 48 +- src/components/NotSpamButton.tsx | 43 -- src/components/ThreadView.tsx | 35 +- src/components/useMailboxMove.ts | 90 ++++ src/lib/__tests__/jmap.test.ts | 63 ++- src/lib/__tests__/mailAutoSync.test.ts | 41 +- src/lib/__tests__/mailbox.test.ts | 73 +++ src/lib/jmap.ts | 266 +++------- src/lib/mailAutoSync.ts | 29 +- src/lib/mailbox.ts | 116 +++++ tests/smoke/mail.spec.ts | 5 + 23 files changed, 941 insertions(+), 932 deletions(-) delete mode 100644 src/components/NotSpamButton.tsx create mode 100644 src/components/useMailboxMove.ts create mode 100644 src/lib/__tests__/mailbox.test.ts create mode 100644 src/lib/mailbox.ts diff --git a/src/app/(inbox)/actions.ts b/src/app/(inbox)/actions.ts index d6de78c..16c160b 100644 --- a/src/app/(inbox)/actions.ts +++ b/src/app/(inbox)/actions.ts @@ -1,11 +1,10 @@ "use server"; import { auth } from "@/auth"; -import { getSession, getAccountId, getMailboxes, listEmails, loadMoreEmailsFiltered, searchEmails, setPin, setKeywordsOnMany, moveEmailsToMailbox, getInboxSnapshot, destroyAllEmailsInMailbox, destroyEmails, getEmailMailboxIds } from "@/lib/jmap"; +import { getSession, getAccountId, getMailboxes, listEmails, loadMoreEmailsFiltered, searchEmails, setPin, setKeywordsOnMany, moveEmailsToMailbox, getEmailState, destroyAllEmailsInMailbox, destroyEmails, getEmailMailboxIds, type EmailPage } from "@/lib/jmap"; import { parseSearchQuery, buildJmapFilter } from "@/lib/search"; import { log } from "@/lib/logger"; -import { Email } from "@/lib/types"; -import type { InboxSnapshot } from "@/lib/mailAutoSync"; +import type { MailSyncSnapshot } from "@/lib/mailAutoSync"; async function requireAuthedJmap() { const sessionData = await auth(); @@ -14,55 +13,65 @@ async function requireAuthedJmap() { return { session, accountId: getAccountId(session) }; } -export async function loadMoreEmails( - inboxId: string, - position: number -): Promise<{ emails: Email[]; total: number }> { - const t = Date.now(); - const { session, accountId } = await requireAuthedJmap(); - const result = await listEmails(session.apiUrl, accountId, inboxId, 50, position); - log.info({ mailbox_id: inboxId, position, limit: 50, returned: result.emails.length, total: result.total, duration_ms: Date.now() - t }, "action.load_more"); - return result; -} - -export async function checkInboxForNewMail( - inboxId: string, -): Promise { - if (!inboxId) return { latestEmailId: null, total: 0 }; - const { session, accountId } = await requireAuthedJmap(); - return getInboxSnapshot(session.apiUrl, accountId, inboxId); -} - -export async function loadMoreUnreads( - inboxId: string, - position: number -): Promise<{ emails: Email[]; total: number }> { +export async function loadMailboxPageAction( + mailboxId: string, + filter: "all" | "unread" | "read", + position: number, +): Promise { + if ( + typeof mailboxId !== "string" || + !mailboxId || + !["all", "unread", "read"].includes(filter) || + !Number.isInteger(position) || + position < 0 + ) { + throw new Error("Invalid mailbox page request"); + } const t = Date.now(); const { session, accountId } = await requireAuthedJmap(); - const result = await loadMoreEmailsFiltered(session.apiUrl, accountId, inboxId, "unread", position); - log.info({ mailbox_id: inboxId, filter: "unread", position, limit: 50, returned: result.emails.length, total: result.total, duration_ms: Date.now() - t }, "action.load_more"); + const result = + filter === "all" + ? await listEmails(session.apiUrl, accountId, mailboxId, 50, position) + : await loadMoreEmailsFiltered( + session.apiUrl, + accountId, + mailboxId, + filter, + position, + ); + log.info({ mailbox_id: mailboxId, filter, position, limit: 50, returned: result.emails.length, total: result.total, duration_ms: Date.now() - t }, "action.load_more"); return result; } -export async function loadMoreReads( - inboxId: string, - position: number -): Promise<{ emails: Email[]; total: number }> { - const t = Date.now(); +export async function checkMailForUpdates(): Promise { const { session, accountId } = await requireAuthedJmap(); - const result = await loadMoreEmailsFiltered(session.apiUrl, accountId, inboxId, "read", position); - log.info({ mailbox_id: inboxId, filter: "read", position, limit: 50, returned: result.emails.length, total: result.total, duration_ms: Date.now() - t }, "action.load_more"); - return result; + return { emailState: await getEmailState(session.apiUrl, accountId) }; } -export async function searchEmailsAction(query: string): Promise { +export async function searchEmailsAction( + query: string, + position = 0, +): Promise { + if ( + typeof query !== "string" || + !Number.isInteger(position) || + position < 0 + ) { + throw new Error("Invalid search request"); + } const t = Date.now(); const { session, accountId } = await requireAuthedJmap(); const parsed = parseSearchQuery(query); const filter = buildJmapFilter(parsed); - const results = await searchEmails(session.apiUrl, accountId, filter); - log.info({ query_len: query.length, results: results.length, duration_ms: Date.now() - t }, "action.search"); - return results; + const result = await searchEmails( + session.apiUrl, + accountId, + filter, + 50, + position, + ); + log.info({ query_len: query.length, position, results: result.emails.length, total: result.total, duration_ms: Date.now() - t }, "action.search"); + return result; } export async function togglePinAction( diff --git a/src/app/(inbox)/email/[id]/actions.ts b/src/app/(inbox)/email/[id]/actions.ts index 93345a3..e8c74e9 100644 --- a/src/app/(inbox)/email/[id]/actions.ts +++ b/src/app/(inbox)/email/[id]/actions.ts @@ -1,9 +1,10 @@ "use server"; import { auth } from "@/auth"; -import { getSession, getAccountId, getIdentities, getMailboxes, markAsRead, markAsUnread, sendCalendarReply, setKeywordsOnMany, moveEmailsToMailbox } from "@/lib/jmap"; +import { getSession, getAccountId, getIdentities, getMailboxes, markAsRead, markAsUnread, sendCalendarReply, setKeywordsOnMany } from "@/lib/jmap"; import { parseIcs, buildCalendarReply } from "@/lib/ics"; import { log } from "@/lib/logger"; +import { resolveMailboxes } from "@/lib/mailbox"; async function requireAuthedJmap() { const sessionData = await auth(); @@ -40,7 +41,7 @@ export async function sendCalendarReplyAction( ]); const identity = identities[0]; if (!identity) throw new Error("No identity found"); - const sentMailboxId = mailboxes.find((m) => m.role === "sent")?.id; + const sentMailboxId = resolveMailboxes(mailboxes).sent?.id; const event = parseIcs(icsText); if (!event) throw new Error("Could not parse calendar event"); @@ -90,16 +91,3 @@ export async function sendCalendarReplyAction( duration_ms: Date.now() - t, }, "action.calendar_reply"); } - -export async function promoteNotSpamAction( - emailId: string, - currentMailboxIds: Record, - inboxMailboxId: string -): Promise { - const t = Date.now(); - const { session, accountId } = await requireAuthedJmap(); - const emails = [{ id: emailId, mailboxIds: currentMailboxIds }]; - await moveEmailsToMailbox(session.apiUrl, accountId, emails, inboxMailboxId); - log.info({ email_id: emailId, target_mailbox_id: inboxMailboxId, duration_ms: Date.now() - t }, "action.promote_not_spam"); -} - diff --git a/src/app/(inbox)/layout.tsx b/src/app/(inbox)/layout.tsx index db119bd..e310058 100644 --- a/src/app/(inbox)/layout.tsx +++ b/src/app/(inbox)/layout.tsx @@ -9,23 +9,15 @@ import EmailListPanel, { import InboxPanelLayout from "@/components/InboxPanelLayout"; import { MailListLoadingSkeleton } from "@/components/LoadingSkeletons"; import { getJmapMailboxContext } from "@/lib/jmapServer"; +import { getMailboxIds, resolveMailboxes } from "@/lib/mailbox"; const EMPTY_DEFERRED_DATA: DeferredMailPanelData = { - drafts: [], - sentEmails: [], - pinnedEmails: [], - spamUnreads: [], - spamUnreadTotal: 0, - spamReads: [], - spamReadTotal: 0, - archiveUnreads: [], - archiveUnreadTotal: 0, - archiveReads: [], - archiveReadTotal: 0, - trashUnreads: [], - trashUnreadTotal: 0, - trashReads: [], - trashReadTotal: 0, + drafts: { emails: [], total: 0 }, + sent: { emails: [], total: 0 }, + pinned: [], + spam: { unreads: [], unreadTotal: 0, reads: [], readTotal: 0 }, + archive: { unreads: [], unreadTotal: 0, reads: [], readTotal: 0 }, + trash: { unreads: [], unreadTotal: 0, reads: [], readTotal: 0 }, }; async function DeferredPanelData({ @@ -58,18 +50,14 @@ async function MailPanelData() { } const { session, accountId, mailboxes } = context; - const inbox = mailboxes.find((m) => m.role === "inbox"); - const draftsMailbox = mailboxes.find((m) => m.role === "drafts"); - const sentMailbox = mailboxes.find((m) => m.role === "sent"); - const archiveMailbox = mailboxes.find((m) => m.role === "archive"); - const trashMailbox = mailboxes.find((m) => m.role === "trash"); - const spamMailbox = mailboxes.find((m) => m.role === "junk" || m.name.toLowerCase() === "spam" || m.name.toLowerCase() === "junk"); + const resolvedMailboxes = resolveMailboxes(mailboxes); + const mailboxIds = getMailboxIds(resolvedMailboxes); const primaryResult = loadMailPanelData( session.apiUrl, accountId, - { inbox: inbox?.id }, - false, + { inbox: mailboxIds.inbox }, + { includeEmailState: true }, ); const deferredResult = primaryResult .then(() => @@ -77,32 +65,23 @@ async function MailPanelData() { session.apiUrl, accountId, { - drafts: draftsMailbox?.id, - sent: sentMailbox?.id, - spam: spamMailbox?.id, - archive: archiveMailbox?.id, - trash: trashMailbox?.id, + drafts: mailboxIds.drafts, + pinned: mailboxIds.inbox, + sent: mailboxIds.sent, + spam: mailboxIds.spam, + archive: mailboxIds.archive, + trash: mailboxIds.trash, }, - 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, - archiveUnreads: data.archive.unreads, - archiveUnreadTotal: data.archive.unreadTotal, - archiveReads: data.archive.reads, - archiveReadTotal: data.archive.readTotal, - trashUnreads: data.trash.unreads, - trashUnreadTotal: data.trash.unreadTotal, - trashReads: data.trash.reads, - trashReadTotal: data.trash.readTotal, + sent: data.sent, + pinned: data.pinned, + spam: data.spam, + archive: data.archive, + trash: data.trash, }), ) .catch((err) => { @@ -120,12 +99,13 @@ async function MailPanelData() { log.error({ err }, "layout.inbox.fetch_error"); panelData = { inbox: { unreads: [], unreadTotal: 0, reads: [], readTotal: 0 }, - drafts: [], + drafts: { emails: [], total: 0 }, pinned: [], sent: { emails: [], total: 0 }, spam: { unreads: [], unreadTotal: 0, reads: [], readTotal: 0 }, archive: { unreads: [], unreadTotal: 0, reads: [], readTotal: 0 }, trash: { unreads: [], unreadTotal: 0, reads: [], readTotal: 0 }, + emailState: undefined, }; } @@ -137,30 +117,15 @@ async function MailPanelData() { read_count: reads.length, read_total: readTotal, pinned_count: panelData.pinned.length, - has_drafts_mailbox: !!draftsMailbox, - has_sent_mailbox: !!sentMailbox, - has_spam_mailbox: !!spamMailbox, + has_drafts_mailbox: !!mailboxIds.drafts, + has_sent_mailbox: !!mailboxIds.sent, + has_spam_mailbox: !!mailboxIds.spam, }, "layout.inbox.load"); return ( diff --git a/src/app/(inbox)/thread/[threadId]/page.tsx b/src/app/(inbox)/thread/[threadId]/page.tsx index f406bbf..14c6921 100644 --- a/src/app/(inbox)/thread/[threadId]/page.tsx +++ b/src/app/(inbox)/thread/[threadId]/page.tsx @@ -11,6 +11,7 @@ import { getJmapMailboxContext, } from "@/lib/jmapServer"; import { sanitizeReaderHtml } from "@/lib/printHtml"; +import { getMailboxIds, resolveMailboxes } from "@/lib/mailbox"; interface Props { params: Promise<{ threadId: string }>; @@ -78,10 +79,7 @@ export default async function ThreadPage({ params, searchParams }: Props) { ), ), ]); - 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 archiveMailbox = mailboxes.find((m) => m.role === "archive"); - const trashMailbox = mailboxes.find((m) => m.role === "trash"); + const mailboxIds = getMailboxIds(resolveMailboxes(mailboxes)); return (
@@ -105,10 +103,7 @@ export default async function ThreadPage({ params, searchParams }: Props) {
diff --git a/src/app/api/send/route.ts b/src/app/api/send/route.ts index add7b1c..daf2e4f 100644 --- a/src/app/api/send/route.ts +++ b/src/app/api/send/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { auth } from "@/auth"; import { getSession, getAccountId, getIdentities, getMailboxes, sendEmail } from "@/lib/jmap"; import { log } from "@/lib/logger"; +import { resolveMailboxes } from "@/lib/mailbox"; function getString(value: unknown): string { return typeof value === "string" ? value : ""; @@ -86,7 +87,7 @@ export async function POST(req: NextRequest) { log.warn({ identity_id: identityId, duration_ms: Date.now() - t }, "route.send.invalid_identity"); return NextResponse.json({ error: "Invalid identity" }, { status: 400 }); } - const sentMailboxId = mailboxes.find((m) => m.role === "sent")?.id; + const sentMailboxId = resolveMailboxes(mailboxes).sent?.id; const result = await sendEmail(session.apiUrl, accountId, { identityId, diff --git a/src/app/compose/actions.ts b/src/app/compose/actions.ts index 06ebc51..c51c775 100644 --- a/src/app/compose/actions.ts +++ b/src/app/compose/actions.ts @@ -11,6 +11,7 @@ import { parseAddresses, } from "@/lib/jmap"; import { log } from "@/lib/logger"; +import { resolveMailboxes } from "@/lib/mailbox"; function splitRaw(raw: string) { return raw @@ -101,7 +102,7 @@ export async function saveDraftAction( getMailboxes(session.apiUrl, accountId), getIdentities(session.apiUrl, accountId), ]); - const draftsMailbox = mailboxes.find((m) => m.role === "drafts"); + const draftsMailbox = resolveMailboxes(mailboxes).drafts; if (!draftsMailbox) throw new Error("No drafts mailbox found"); const identity = identities.find((candidate) => candidate.id === identityId); if (!identity) throw new Error("Invalid from address"); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 2936145..f4bab02 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -14,6 +14,7 @@ import UnreadCountProvider, { } from "@/components/UnreadCountProvider"; import { getJmapMailboxContext } from "@/lib/jmapServer"; import { APPEARANCE_BOOTSTRAP_SCRIPT } from "@/lib/appearance"; +import { resolveMailboxes } from "@/lib/mailbox"; import "./globals.css"; export const metadata: Metadata = { @@ -24,14 +25,7 @@ export const metadata: Metadata = { 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", - ); + const { inbox, drafts, spam } = resolveMailboxes(mailboxes); return ( !email.keywords["$seen"]); + const reads = emails.filter((email) => email.keywords["$seen"]); + const emptySplit = { + unreads: [] as Email[], + unreadTotal: 0, + reads: [] as Email[], + readTotal: 0, + }; + return { + inbox: { + unreads, + unreadTotal: unreads.length, + reads, + readTotal: reads.length, + }, + drafts: { emails: [], total: 0 }, + pinned: [], + sent: { emails: [], total: 0 }, + spam: emptySplit, + archive: emptySplit, + trash: emptySplit, + emailState, + }; +} + const fixtureAttachments: EmailBodyPart[] = [ { blobId: "blob-sheet", @@ -212,8 +242,7 @@ export default function SmokeHarness({ panel }: { panel: SmokePanel }) { : [incomingEmail, ...current], ); return { - latestEmailId: incomingEmail.id, - total: fixtureEmails.length + 1, + emailState: "state-with-incoming-email", }; }, []); @@ -248,13 +277,13 @@ export default function SmokeHarness({ panel }: { panel: SmokePanel }) {
{panel === "inbox" && ( @@ -262,17 +291,13 @@ export default function SmokeHarness({ panel }: { panel: SmokePanel }) { {panel === "auto-sync" && ( !email.keywords["$seen"], + initialData={makePanelData( + autoSyncEmails, + autoSyncEmails.length > fixtureEmails.length + ? "state-with-incoming-email" + : "state-initial", )} - unreadTotal={ - autoSyncEmails.filter((email) => !email.keywords["$seen"]).length - } - reads={autoSyncEmails.filter((email) => email.keywords["$seen"])} - readTotal={ - autoSyncEmails.filter((email) => email.keywords["$seen"]).length - } - inboxId="mailbox-inbox" + mailboxIds={{ inbox: "mailbox-inbox" }} threadHrefPrefix="/smoke-tests/thread" autoSyncIntervalMs={700} autoSyncCheck={runAutoSyncCheck} @@ -349,7 +374,12 @@ export default function SmokeHarness({ panel }: { panel: SmokePanel }) { initiallyPinned={false} isSpam={false} mailboxIds={{ "mailbox-inbox": true }} - inboxMailboxId="mailbox-inbox" + systemMailboxIds={{ + inbox: "mailbox-inbox", + archive: "mailbox-archive", + trash: "mailbox-trash", + spam: "mailbox-spam", + }} className="border-t border-stone-100 dark:border-stone-700/70" />
diff --git a/src/components/DesktopNav.tsx b/src/components/DesktopNav.tsx index a78f293..a7d7f37 100644 --- a/src/components/DesktopNav.tsx +++ b/src/components/DesktopNav.tsx @@ -4,21 +4,26 @@ import Link from "next/link"; import { usePathname, useSearchParams } from "next/navigation"; import LiveUnreadCountBadge from "@/components/LiveUnreadCountBadge"; import MailIcon, { type MailIconName } from "@/components/MailIcon"; +import { + isMailViewActive, + type MailView, +} from "@/lib/mailbox"; interface NavItem { href: string; label: string; icon: MailIconName; badge?: "inbox" | "drafts" | "spam"; + view?: MailView; } const mailboxItems: NavItem[] = [ - { href: "/", label: "Inbox", icon: "inbox", badge: "inbox" }, - { href: "/drafts", label: "Drafts", icon: "drafts", badge: "drafts" }, - { href: "/sent", label: "Sent", icon: "sent" }, - { href: "/archive", label: "Archive", icon: "archive" }, - { href: "/trash", label: "Trash", icon: "trash" }, - { href: "/spam", label: "Spam", icon: "spam", badge: "spam" }, + { href: "/", label: "Inbox", icon: "inbox", badge: "inbox", view: "inbox" }, + { href: "/drafts", label: "Drafts", icon: "drafts", badge: "drafts", view: "drafts" }, + { href: "/sent", label: "Sent", icon: "sent", view: "sent" }, + { href: "/archive", label: "Archive", icon: "archive", view: "archive" }, + { href: "/trash", label: "Trash", icon: "trash", view: "trash" }, + { href: "/spam", label: "Spam", icon: "spam", badge: "spam", view: "spam" }, { href: "/calendar", label: "Calendar", icon: "calendar" }, ]; @@ -27,30 +32,7 @@ function isActive( pathname: string, from: string | null, ): boolean { - if (item.href === "/") { - return ( - (pathname === "/" || - pathname.startsWith("/email/") || - pathname.startsWith("/thread/") || - pathname.startsWith("/attachment/")) && - from !== "spam" && - from !== "sent" && - from !== "archive" && - from !== "trash" - ); - } - if (item.href === "/sent") { - return pathname.startsWith("/sent") || from === "sent"; - } - if (item.href === "/spam") { - return pathname.startsWith("/spam") || from === "spam"; - } - if (item.href === "/archive") { - return pathname.startsWith("/archive") || from === "archive"; - } - if (item.href === "/trash") { - return pathname.startsWith("/trash") || from === "trash"; - } + if (item.view) return isMailViewActive(item.view, pathname, from); return pathname.startsWith(item.href); } diff --git a/src/components/EmailDetailView.tsx b/src/components/EmailDetailView.tsx index fcd6259..0fb228e 100644 --- a/src/components/EmailDetailView.tsx +++ b/src/components/EmailDetailView.tsx @@ -9,6 +9,7 @@ import SenderAvatar from "@/components/SenderAvatar"; import MessageActionBar from "@/components/MessageActionBar"; import Popover from "@/components/Popover"; import { sanitizeReaderHtml } from "@/lib/printHtml"; +import { getMailboxIds, resolveMailboxes } from "@/lib/mailbox"; interface Props { email: Email; @@ -42,17 +43,10 @@ export default async function EmailDetailView({ email, downloadUrl, accountId }: getJmapMailboxContext(), ]); - // ── Detect spam/junk mailbox ──────────────────────────────────── - const spamMailbox = mailboxes.find( - (m) => - m.role === "junk" || - m.name.toLowerCase() === "spam" || - m.name.toLowerCase() === "junk" + const mailboxIds = getMailboxIds(resolveMailboxes(mailboxes)); + const isSpam = !!( + mailboxIds.spam && email.mailboxIds[mailboxIds.spam] ); - const inboxMailbox = mailboxes.find((m) => m.role === "inbox"); - const archiveMailbox = mailboxes.find((m) => m.role === "archive"); - const trashMailbox = mailboxes.find((m) => m.role === "trash"); - const isSpam = !!(spamMailbox && email.mailboxIds[spamMailbox.id]); const hasMultipleRecipients = (email.to?.length ?? 0) + (email.cc?.length ?? 0) > 1; @@ -137,9 +131,7 @@ export default async function EmailDetailView({ email, downloadUrl, accountId }: initiallyPinned={!!email.keywords?.["$flagged"]} isSpam={isSpam} mailboxIds={email.mailboxIds} - inboxMailboxId={inboxMailbox?.id} - archiveMailboxId={archiveMailbox?.id} - trashMailboxId={trashMailbox?.id} + systemMailboxIds={mailboxIds} className="border-t border-stone-100 dark:border-stone-700/70" /> diff --git a/src/components/EmailListPanel.tsx b/src/components/EmailListPanel.tsx index ecda46d..4c4ac00 100644 --- a/src/components/EmailListPanel.tsx +++ b/src/components/EmailListPanel.tsx @@ -37,12 +37,10 @@ import { formatDate } from "@/lib/format"; import { bulkMarkAsRead, bulkMarkAsUnread, - bulkMoveToMailbox, bulkSetPin, - checkInboxForNewMail, + checkMailForUpdates, emptyTrashAction, - loadMoreReads, - loadMoreUnreads, + loadMailboxPageAction, permanentlyDeleteEmailsAction, searchEmailsAction, } from "@/app/(inbox)/actions"; @@ -51,61 +49,33 @@ import { dispatchUnreadCountEvent, getReadEmailIds, getUnreadEmailIds, isEmailUn import { MAIL_AUTO_SYNC_INTERVAL_MS, canRunImmediateMailSync, - getInboxSnapshot, getMailAutoSyncDelay, - inboxSnapshotKey, - type InboxSnapshot, + mailSyncSnapshotKey, + type MailSyncSnapshot, } from "@/lib/mailAutoSync"; +import { + getMailView, + getMailboxIdForView, + isThreadMailView, + MAIL_VIEW_LABELS, + type MailboxIds, +} from "@/lib/mailbox"; +import type { MailPanelData } from "@/lib/jmap"; +import useMailboxMove from "@/components/useMailboxMove"; interface Props { - unreads: Email[]; - unreadTotal: number; - reads: Email[]; - readTotal: number; - inboxId: string; - drafts?: Email[]; - sentEmails?: Email[]; - pinnedEmails?: Email[]; - archiveMailboxId?: string; - trashMailboxId?: string; - spamUnreads?: Email[]; - spamUnreadTotal?: number; - spamReads?: Email[]; - spamReadTotal?: number; - spamMailboxId?: string; - archiveUnreads?: Email[]; - archiveUnreadTotal?: number; - archiveReads?: Email[]; - archiveReadTotal?: number; - trashUnreads?: Email[]; - trashUnreadTotal?: number; - trashReads?: Email[]; - trashReadTotal?: number; + initialData: MailPanelData; + mailboxIds: MailboxIds; deferredContent?: ReactNode; threadHrefPrefix?: string; autoSyncIntervalMs?: number; - autoSyncCheck?: (inboxId: string) => Promise; + autoSyncCheck?: () => Promise; } -type View = "inbox" | "drafts" | "sent" | "spam" | "archive" | "trash"; - -export interface DeferredMailPanelData { - drafts: Email[]; - sentEmails: Email[]; - pinnedEmails: Email[]; - spamUnreads: Email[]; - spamUnreadTotal: number; - spamReads: Email[]; - spamReadTotal: number; - archiveUnreads: Email[]; - archiveUnreadTotal: number; - archiveReads: Email[]; - archiveReadTotal: number; - trashUnreads: Email[]; - trashUnreadTotal: number; - trashReads: Email[]; - trashReadTotal: number; -} +export type DeferredMailPanelData = Pick< + MailPanelData, + "drafts" | "pinned" | "sent" | "spam" | "archive" | "trash" +>; const DeferredMailPanelContext = createContext< ((data: DeferredMailPanelData) => void) | null @@ -206,39 +176,25 @@ function IconRefresh({ spinning = false }: { spinning?: boolean }) { // --------------------------------------------------------------------------- export default function EmailListPanel({ - unreads, - unreadTotal, - reads, - readTotal, - inboxId, - drafts: initialDrafts = [], - sentEmails: initialSentEmails = [], - pinnedEmails = [], - archiveMailboxId, - trashMailboxId, - spamUnreads: initialSpamUnreads = [], - spamUnreadTotal: initialSpamUnreadTotal = 0, - spamReads: initialSpamReads = [], - spamReadTotal: initialSpamReadTotal = 0, - spamMailboxId, - archiveUnreads: initialArchiveUnreads = [], - archiveUnreadTotal: initialArchiveUnreadTotal = 0, - archiveReads: initialArchiveReads = [], - archiveReadTotal: initialArchiveReadTotal = 0, - trashUnreads: initialTrashUnreads = [], - trashUnreadTotal: initialTrashUnreadTotal = 0, - trashReads: initialTrashReads = [], - trashReadTotal: initialTrashReadTotal = 0, + initialData, + mailboxIds, deferredContent, threadHrefPrefix = "/thread", autoSyncIntervalMs = MAIL_AUTO_SYNC_INTERVAL_MS, - autoSyncCheck = checkInboxForNewMail, + autoSyncCheck = checkMailForUpdates, }: Props) { + const { + inbox: { unreads, unreadTotal, reads, readTotal }, + } = initialData; + const inboxId = mailboxIds.inbox ?? ""; + const archiveMailboxId = mailboxIds.archive; + const trashMailboxId = mailboxIds.trash; const pathname = usePathname(); const router = useRouter(); const searchParams = useSearchParams(); const confirmNavigation = useConfirmNavigation(); const showToast = useToast(); + const { moveEmails: moveEmailsBetweenMailboxes } = useMailboxMove(); const selectedThreadId = pathname.startsWith("/thread/") ? pathname.slice("/thread/".length) : undefined; @@ -248,17 +204,7 @@ export default function EmailListPanel({ ? pathname.slice("/email/".length) : undefined; - const view: View = pathname.startsWith("/drafts") - ? "drafts" - : pathname.startsWith("/sent") || searchParams.get("from") === "sent" - ? "sent" - : pathname.startsWith("/archive") || searchParams.get("from") === "archive" - ? "archive" - : pathname.startsWith("/trash") || searchParams.get("from") === "trash" - ? "trash" - : pathname.startsWith("/spam") || searchParams.get("from") === "spam" - ? "spam" - : "inbox"; + const view = getMailView(pathname, searchParams.get("from")); // ------------------------------------------------------------------------- // Read / unread client-side overrides @@ -299,52 +245,22 @@ 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 [draftsData, setDraftsData] = useState(initialData.drafts); + const [pinnedList, setPinnedList] = useState(initialData.pinned); const [deferredPending, setDeferredPending] = useState(!!deferredContent); - const [sentList, setSentList] = useState(initialSentEmails); - const [spamData, setSpamData] = useState({ - unreads: initialSpamUnreads, - unreadTotal: initialSpamUnreadTotal, - reads: initialSpamReads, - readTotal: initialSpamReadTotal, - }); - const [archiveData, setArchiveData] = useState({ - unreads: initialArchiveUnreads, - unreadTotal: initialArchiveUnreadTotal, - reads: initialArchiveReads, - readTotal: initialArchiveReadTotal, - }); - const [trashData, setTrashData] = useState({ - unreads: initialTrashUnreads, - unreadTotal: initialTrashUnreadTotal, - reads: initialTrashReads, - readTotal: initialTrashReadTotal, - }); + const [sentData, setSentData] = useState(initialData.sent); + const [spamData, setSpamData] = useState(initialData.spam); + const [archiveData, setArchiveData] = useState(initialData.archive); + const [trashData, setTrashData] = useState(initialData.trash); 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, - }); - setArchiveData({ - unreads: data.archiveUnreads, - unreadTotal: data.archiveUnreadTotal, - reads: data.archiveReads, - readTotal: data.archiveReadTotal, - }); - setTrashData({ - unreads: data.trashUnreads, - unreadTotal: data.trashUnreadTotal, - reads: data.trashReads, - readTotal: data.trashReadTotal, - }); + setDraftsData(data.drafts); + setSentData(data.sent); + setPinnedList(data.pinned); + setSpamData(data.spam); + setArchiveData(data.archive); + setTrashData(data.trash); setDeferredPending(false); }, []); @@ -360,14 +276,7 @@ export default function EmailListPanel({ const currentUnreadTotal = currentData.unreadTotal; const currentReads = currentData.reads; const currentReadTotal = currentData.readTotal; - const currentMailboxId = - view === "spam" - ? spamMailboxId ?? "" - : view === "archive" - ? archiveMailboxId ?? "" - : view === "trash" - ? trashMailboxId ?? "" - : inboxId; + const currentMailboxId = getMailboxIdForView(mailboxIds, view) ?? ""; useEffect(() => { setExtraUnreads([]); @@ -415,20 +324,18 @@ export default function EmailListPanel({ const refreshTimer2 = useRef | undefined>(undefined); const transitionPendingRef = useRef(isPending); transitionPendingRef.current = isPending; - const initialInboxSnapshot = useMemo( - () => getInboxSnapshot(unreads, unreadTotal, reads, readTotal), - [unreads, unreadTotal, reads, readTotal], - ); - const inboxSnapshotKeyValue = inboxSnapshotKey(initialInboxSnapshot); - const knownInboxSnapshotRef = useRef(inboxSnapshotKeyValue); + const initialMailSyncKey = initialData.emailState ?? ""; + const knownMailSyncRef = useRef(initialMailSyncKey); const [syncAnnouncement, setSyncAnnouncement] = useState(""); useEffect(() => { - knownInboxSnapshotRef.current = inboxSnapshotKeyValue; - }, [inboxSnapshotKeyValue]); + knownMailSyncRef.current = initialMailSyncKey; + setExtraUnreads([]); + setExtraReads([]); + }, [initialMailSyncKey]); useEffect(() => { - if (!inboxId || autoSyncIntervalMs <= 0) return; + if (!initialMailSyncKey || autoSyncIntervalMs <= 0) return; let stopped = false; let inFlight = false; @@ -473,14 +380,14 @@ export default function EmailListPanel({ inFlight = true; lastCheckAt = now; try { - const snapshot = await autoSyncCheck(inboxId); + const snapshot = await autoSyncCheck(); if (stopped || !canSync()) return; consecutiveFailures = 0; - const nextSnapshotKey = inboxSnapshotKey(snapshot); - if (nextSnapshotKey !== knownInboxSnapshotRef.current) { - knownInboxSnapshotRef.current = nextSnapshotKey; - setSyncAnnouncement(`Inbox updated at ${new Date().toLocaleTimeString()}`); + const nextSnapshotKey = mailSyncSnapshotKey(snapshot); + if (nextSnapshotKey !== knownMailSyncRef.current) { + knownMailSyncRef.current = nextSnapshotKey; + setSyncAnnouncement(`Mail updated at ${new Date().toLocaleTimeString()}`); startTransition(() => router.refresh()); } } catch { @@ -521,7 +428,13 @@ export default function EmailListPanel({ window.removeEventListener("online", resumeSync); window.removeEventListener("offline", clearScheduledCheck); }; - }, [autoSyncCheck, autoSyncIntervalMs, inboxId, router, startTransition]); + }, [ + autoSyncCheck, + autoSyncIntervalMs, + initialMailSyncKey, + router, + startTransition, + ]); function applySelection(next: Set) { setSelectedIds(next); @@ -639,6 +552,7 @@ export default function EmailListPanel({ // ------------------------------------------------------------------------- const [searchQuery, setSearchQuery] = useState(""); const [searchResults, setSearchResults] = useState([]); + const [searchTotal, setSearchTotal] = useState(0); const [isSearching, setIsSearching] = useState(false); const [searchFocused, setSearchFocused] = useState(false); const searchInputRef = useRef(null); @@ -699,10 +613,12 @@ export default function EmailListPanel({ const loadedReads = currentReads.length + extraReads.length; const hasMoreUnreads = loadedUnreads < currentUnreadTotal; const hasMoreReads = loadedReads < currentReadTotal; - const hasMore = !isInSearchMode && (hasMoreUnreads || hasMoreReads); - + const hasMoreThreadMail = hasMoreUnreads || hasMoreReads; + const hasMoreSearch = searchResults.length < searchTotal; + const hasMoreDrafts = draftsData.emails.length < draftsData.total; + const hasMoreSent = sentData.emails.length < sentData.total; const unreadCount = useUnreadCount(); - const draftCount = draftsList.length; + const draftCount = draftsData.total; const pinnedThreadCount = isInSearchMode ? 0 : visibleThreads.filter((t) => t.isPinned).length; @@ -712,18 +628,30 @@ export default function EmailListPanel({ // ------------------------------------------------------------------------- useEffect(() => { const q = searchQuery.trim(); - if (!q) { setSearchResults([]); setIsSearching(false); return; } + if (!q) { + setSearchResults([]); + setSearchTotal(0); + setIsSearching(false); + return; + } + let cancelled = false; const timer = setTimeout(async () => { setIsSearching(true); try { - const results = await searchEmailsAction(q); - setSearchResults(results); + const result = await searchEmailsAction(q); + if (!cancelled) { + setSearchResults(result.emails); + setSearchTotal(result.total); + } } finally { - setIsSearching(false); + if (!cancelled) setIsSearching(false); } }, 400); - return () => clearTimeout(timer); - }, [searchQuery]); + return () => { + cancelled = true; + clearTimeout(timer); + }; + }, [searchQuery, initialMailSyncKey]); // ------------------------------------------------------------------------- // Auto-mark as read when navigating to a thread @@ -758,11 +686,46 @@ export default function EmailListPanel({ async function handleLoadMore() { setLoadingMore(true); try { - if (hasMoreUnreads) { - const { emails: more } = await loadMoreUnreads(currentMailboxId, loadedUnreads); + if (isInSearchMode) { + const result = await searchEmailsAction( + searchQuery.trim(), + searchResults.length, + ); + setSearchResults((previous) => [...previous, ...result.emails]); + setSearchTotal(result.total); + } else if (view === "drafts" || view === "sent") { + const page = view === "drafts" ? draftsData : sentData; + const mailboxId = getMailboxIdForView(mailboxIds, view); + if (!mailboxId) return; + const result = await loadMailboxPageAction( + mailboxId, + "all", + page.emails.length, + ); + if (view === "drafts") { + setDraftsData((previous) => ({ + emails: [...previous.emails, ...result.emails], + total: result.total, + })); + } else { + setSentData((previous) => ({ + emails: [...previous.emails, ...result.emails], + total: result.total, + })); + } + } else if (hasMoreUnreads) { + const { emails: more } = await loadMailboxPageAction( + currentMailboxId, + "unread", + loadedUnreads, + ); setExtraUnreads((prev) => [...prev, ...more]); } else { - const { emails: more } = await loadMoreReads(currentMailboxId, loadedReads); + const { emails: more } = await loadMailboxPageAction( + currentMailboxId, + "read", + loadedReads, + ); setExtraReads((prev) => [...prev, ...more]); } } finally { @@ -805,13 +768,25 @@ export default function EmailListPanel({ // ------------------------------------------------------------------------- // Bulk actions // ------------------------------------------------------------------------- + const selectedInboxEmails = useMemo( + () => + visibleEmails.filter( + (email) => + selectedIds.has(email.id) && + !!inboxId && + !!email.mailboxIds[inboxId], + ), + [inboxId, selectedIds, visibleEmails], + ); const allSelectedPinned = useMemo( - () => [...selectedIds].every((id) => { - const email = visibleEmails.find((e) => e.id === id); - return email ? isPinned(email) : false; - }), - [selectedIds, visibleEmails] + () => + selectedInboxEmails.length > 0 && + selectedInboxEmails.every(isPinned), + [selectedInboxEmails], ); + const canPinSelection = + selectedInboxEmails.length > 0 && + selectedInboxEmails.length === selectedIds.size; async function handleBulkMarkRead() { const selectedEmails = visibleEmails.filter((email) => selectedIds.has(email.id)); @@ -846,7 +821,8 @@ export default function EmailListPanel({ } async function handleBulkPin() { - const ids = [...selectedIds]; + const ids = selectedInboxEmails.map((email) => email.id); + if (!ids.length) return; const pin = !allSelectedPinned; clearSelection(); ids.forEach((id) => @@ -872,47 +848,27 @@ export default function EmailListPanel({ ) { const ids = emails.map((e) => e.id); const sourceMailboxId = currentMailboxId; - setArchivedIds((prev) => new Set([...prev, ...ids])); - clearSelection(); - const movePromise = bulkMoveToMailbox( - emails.map((e) => ({ id: e.id, mailboxIds: e.mailboxIds })), - targetMailboxId - ); - showToast({ - message: + await moveEmailsBetweenMailboxes({ + emails, + sourceMailboxId, + targetMailboxId, + successMessage: successMessage ?? (targetMailboxId === trashMailboxId ? ids.length === 1 ? "Moved to trash" : `${ids.length} messages moved to trash` : ids.length === 1 ? "Archived" : `${ids.length} messages archived`), - actionLabel: "Undo", - onAction: async () => { - await movePromise; + onOptimistic: () => { + setArchivedIds((prev) => new Set([...prev, ...ids])); + clearSelection(); + }, + onRevert: () => { setArchivedIds((previous) => { const next = new Set(previous); ids.forEach((id) => next.delete(id)); return next; }); - await bulkMoveToMailbox( - emails.map((email) => ({ - id: email.id, - mailboxIds: { [targetMailboxId]: true }, - })), - sourceMailboxId, - ); - router.refresh(); }, }); - try { - await movePromise; - router.refresh(); - } catch { - setArchivedIds((previous) => { - const next = new Set(previous); - ids.forEach((id) => next.delete(id)); - return next; - }); - showToast({ message: "Could not move those messages.", tone: "error" }); - } } async function handleBulkMove(targetMailboxId: string) { @@ -922,26 +878,13 @@ export default function EmailListPanel({ async function handleBulkNotSpam() { const emails = visibleEmails.filter((e) => selectedIds.has(e.id)); - const ids = emails.map((e) => e.id); - setArchivedIds((prev) => new Set([...prev, ...ids])); - clearSelection(); - try { - await bulkMoveToMailbox( - emails.map((e) => ({ id: e.id, mailboxIds: e.mailboxIds })), - inboxId - ); - showToast({ - message: ids.length === 1 ? "Moved to inbox" : `${ids.length} messages moved to inbox`, - }); - router.refresh(); - } catch { - setArchivedIds((previous) => { - const next = new Set(previous); - ids.forEach((id) => next.delete(id)); - return next; - }); - showToast({ message: "Could not move those messages.", tone: "error" }); - } + await moveMessages( + emails, + inboxId, + emails.length === 1 + ? "Moved to Inbox" + : `${emails.length} messages moved to Inbox`, + ); } async function handleBulkRestore() { @@ -1209,23 +1152,8 @@ export default function EmailListPanel({ ? visibleThreads.find((thread) => thread.threadId === keyboardThreadId) ?.latestEmail.subject || "(no subject)" : ""; - const viewLabel = - view === "inbox" - ? "Inbox" - : view === "drafts" - ? "Drafts" - : view === "sent" - ? "Sent" - : view === "spam" - ? "Spam" - : view === "archive" - ? "Archive" - : "Trash"; - const isThreadMailboxView = - view === "inbox" || - view === "spam" || - view === "archive" || - view === "trash"; + const viewLabel = MAIL_VIEW_LABELS[view]; + const isThreadMailboxView = isThreadMailView(view); const isDeferredThreadMailbox = view === "spam" || view === "archive" || view === "trash"; @@ -1274,7 +1202,7 @@ export default function EmailListPanel({ - {view === "inbox" && ( + {view === "inbox" && canPinSelection && ( - {view === "inbox" && ( + {view === "inbox" && pinnableEmails.length > 0 && ( @@ -2035,7 +1970,7 @@ export default function EmailListPanel({
{deferredPending ? ( - ) : draftsList.length === 0 ? ( + ) : draftsData.emails.length === 0 ? ( ) : ( - draftsList.map((draft) => ( -
- + {draftsData.emails.map((draft) => ( +
-
- - {draft.to?.map((a) => a.name ?? a.email).join(", ") || "(no recipient)"} - - - {formatDate(draft.receivedAt)} - -
-
- {draft.subject || "(no subject)"} -
- + +
+ + {draft.to?.map((a) => a.name ?? a.email).join(", ") || "(no recipient)"} + + + {formatDate(draft.receivedAt)} + +
+
+ {draft.subject || "(no subject)"} +
+ + +
+ ))} + {hasMoreDrafts && ( -
- )) + )} + )}
)} @@ -2089,7 +2040,7 @@ export default function EmailListPanel({
{deferredPending ? ( - ) : sentList.length === 0 ? ( + ) : sentData.emails.length === 0 ? ( ) : ( - sentList.map((email) => ( - -
- - {email.to?.map((a) => a.name ?? a.email).join(", ") || "(no recipient)"} - - - {formatDate(email.receivedAt)} - -
-
- {email.subject || "(no subject)"} -
- {email.preview && ( -

- {email.preview} -

- )} - - )) + <> + {sentData.emails.map((email) => ( + +
+ + {email.to?.map((a) => a.name ?? a.email).join(", ") || "(no recipient)"} + + + {formatDate(email.receivedAt)} + +
+
+ {email.subject || "(no subject)"} +
+ {email.preview && ( +

+ {email.preview} +

+ )} + + ))} + {hasMoreSent && ( + + )} + )}
)} diff --git a/src/components/MessageActionBar.tsx b/src/components/MessageActionBar.tsx index f3b9c3f..3a9e731 100644 --- a/src/components/MessageActionBar.tsx +++ b/src/components/MessageActionBar.tsx @@ -3,16 +3,18 @@ import Link from "next/link"; import { useState, type ReactNode } from "react"; import { useRouter } from "next/navigation"; -import { - bulkMoveToMailbox, - permanentlyDeleteEmailsAction, -} from "@/app/(inbox)/actions"; +import { permanentlyDeleteEmailsAction } from "@/app/(inbox)/actions"; import MailIcon from "@/components/MailIcon"; import MarkUnreadButton from "@/components/MarkUnreadButton"; -import NotSpamButton from "@/components/NotSpamButton"; import PinButton from "@/components/PinButton"; import Popover from "@/components/Popover"; import { useToast } from "@/components/ToastProvider"; +import useMailboxMove from "@/components/useMailboxMove"; +import { + getMailboxViewForEmail, + MAIL_VIEW_PATHS, + type MailboxIds, +} from "@/lib/mailbox"; interface Props { emailId: string; @@ -20,9 +22,7 @@ interface Props { initiallyPinned: boolean; isSpam: boolean; mailboxIds: Record; - inboxMailboxId?: string; - archiveMailboxId?: string; - trashMailboxId?: string; + systemMailboxIds: MailboxIds; className?: string; } @@ -54,69 +54,45 @@ export default function MessageActionBar({ initiallyPinned, isSpam, mailboxIds, - inboxMailboxId, - archiveMailboxId, - trashMailboxId, + systemMailboxIds, className = "", }: Props) { const router = useRouter(); const showToast = useToast(); - const [busyAction, setBusyAction] = useState(null); + const { moveEmails, movingTo } = useMailboxMove(); + const [deleting, setDeleting] = useState(false); + const inboxMailboxId = systemMailboxIds.inbox; + const archiveMailboxId = systemMailboxIds.archive; + const trashMailboxId = systemMailboxIds.trash; + const spamMailboxId = systemMailboxIds.spam; const showNotSpam = isSpam && !!inboxMailboxId; const secondaryColumns = showNotSpam ? "col-span-3" : "col-span-2"; const isInbox = !!(inboxMailboxId && mailboxIds[inboxMailboxId]); const isArchive = !!(archiveMailboxId && mailboxIds[archiveMailboxId]); const isTrash = !!(trashMailboxId && mailboxIds[trashMailboxId]); - const sourceMailboxId = isTrash - ? trashMailboxId - : isArchive - ? archiveMailboxId - : isInbox - ? inboxMailboxId - : Object.keys(mailboxIds)[0]; - const sourcePath = isTrash - ? "/trash" - : isArchive - ? "/archive" - : isSpam - ? "/spam" - : "/"; + const sourceView = getMailboxViewForEmail(mailboxIds, systemMailboxIds); + const sourceMailboxId = + (sourceView && systemMailboxIds[sourceView]) ?? Object.keys(mailboxIds)[0]; + const sourcePath = sourceView ? MAIL_VIEW_PATHS[sourceView] : "/"; + const busy = deleting || !!movingTo; async function moveMessage( targetMailboxId: string, successMessage: string, ) { - if (!sourceMailboxId || busyAction) return; - setBusyAction(targetMailboxId); - const movePromise = bulkMoveToMailbox( - [{ id: emailId, mailboxIds }], + if (!sourceMailboxId || busy) return; + await moveEmails({ + emails: [{ id: emailId, mailboxIds }], + sourceMailboxId, targetMailboxId, - ); - showToast({ - message: successMessage, - actionLabel: "Undo", - onAction: async () => { - await movePromise; - await bulkMoveToMailbox( - [{ id: emailId, mailboxIds: { [targetMailboxId]: true } }], - sourceMailboxId, - ); - router.refresh(); - }, + successMessage, + failureMessage: "Could not move this message.", + navigateTo: sourcePath, }); - try { - await movePromise; - router.replace(sourcePath); - router.refresh(); - } catch { - showToast({ message: "Could not move this message.", tone: "error" }); - } finally { - setBusyAction(null); - } } async function permanentlyDeleteMessage() { - if (!trashMailboxId || busyAction) return; + if (!trashMailboxId || busy) return; if ( !window.confirm( "Permanently delete this message? This cannot be undone.", @@ -125,7 +101,7 @@ export default function MessageActionBar({ return; } - setBusyAction("delete"); + setDeleting(true); try { await permanentlyDeleteEmailsAction([emailId], trashMailboxId); showToast({ message: "Message permanently deleted" }); @@ -137,7 +113,7 @@ export default function MessageActionBar({ tone: "error", }); } finally { - setBusyAction(null); + setDeleting(false); } } @@ -177,17 +153,22 @@ export default function MessageActionBar({
{showNotSpam && ( - + )} - - - + {isInbox && ( + + + + )} @@ -224,7 +205,7 @@ export default function MessageActionBar({ )} + {!isSpam && + !isTrash && + spamMailboxId && + (isInbox || isArchive) && ( + + )} {!isTrash && trashMailboxId && (isInbox || isArchive || isSpam) && ( - ); -} diff --git a/src/components/ThreadView.tsx b/src/components/ThreadView.tsx index 557b3f9..ad9323d 100644 --- a/src/components/ThreadView.tsx +++ b/src/components/ThreadView.tsx @@ -9,6 +9,7 @@ import MessageActionBar from "@/components/MessageActionBar"; import { Email } from "@/lib/types"; import { formatAddressList, formatFullDate } from "@/lib/format"; import { visibleAttachments } from "@/lib/attachments"; +import type { MailboxIds } from "@/lib/mailbox"; // --------------------------------------------------------------------------- // Body resolution (mirrors email/[id]/page.tsx logic) @@ -46,10 +47,7 @@ interface ItemProps { onResponseSent: (r: CalendarResponse) => void; expanded: boolean; onToggle: () => void; - spamMailboxId?: string; - inboxMailboxId?: string; - archiveMailboxId?: string; - trashMailboxId?: string; + systemMailboxIds: MailboxIds; } function EmailStackItem({ @@ -59,14 +57,14 @@ function EmailStackItem({ onResponseSent, expanded, onToggle, - spamMailboxId, - inboxMailboxId, - archiveMailboxId, - trashMailboxId, + systemMailboxIds, }: ItemProps) { const isUnread = !email.keywords?.["$seen"]; const resolved = resolveBody(email); - const isSpam = !!(spamMailboxId && email.mailboxIds[spamMailboxId]); + const isSpam = !!( + systemMailboxIds.spam && + email.mailboxIds[systemMailboxIds.spam] + ); const downloadableAttachments = visibleAttachments(email.attachments); const hasMultipleRecipients = (email.to?.length ?? 0) + (email.cc?.length ?? 0) > 1; @@ -172,9 +170,7 @@ function EmailStackItem({ initiallyPinned={!!email.keywords?.["$flagged"]} isSpam={isSpam} mailboxIds={email.mailboxIds} - inboxMailboxId={inboxMailboxId} - archiveMailboxId={archiveMailboxId} - trashMailboxId={trashMailboxId} + systemMailboxIds={systemMailboxIds} className="border-b border-stone-100 dark:border-stone-700/50" /> @@ -222,19 +218,13 @@ function EmailStackItem({ interface Props { emails: Email[]; // sorted oldest → newest calendarEvents: (CalendarEventData | null)[]; - spamMailboxId?: string; - inboxMailboxId?: string; - archiveMailboxId?: string; - trashMailboxId?: string; + systemMailboxIds: MailboxIds; } export default function ThreadView({ emails, calendarEvents, - spamMailboxId, - inboxMailboxId, - archiveMailboxId, - trashMailboxId, + systemMailboxIds, }: Props) { // Start with the most recent email expanded const lastId = emails[emails.length - 1]?.id; @@ -269,10 +259,7 @@ export default function ThreadView({ onResponseSent={(r) => setRsvpResponses((prev) => ({ ...prev, [email.id]: r }))} expanded={expandedIds.has(email.id)} onToggle={() => toggle(email.id)} - spamMailboxId={spamMailboxId} - inboxMailboxId={inboxMailboxId} - archiveMailboxId={archiveMailboxId} - trashMailboxId={trashMailboxId} + systemMailboxIds={systemMailboxIds} /> ))}
diff --git a/src/components/useMailboxMove.ts b/src/components/useMailboxMove.ts new file mode 100644 index 0000000..80adf0d --- /dev/null +++ b/src/components/useMailboxMove.ts @@ -0,0 +1,90 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { useRouter } from "next/navigation"; +import { bulkMoveToMailbox } from "@/app/(inbox)/actions"; +import { useToast } from "@/components/ToastProvider"; + +interface MoveEmail { + id: string; + mailboxIds: Record; +} + +interface MoveOptions { + emails: MoveEmail[]; + sourceMailboxId: string; + targetMailboxId: string; + successMessage: string; + failureMessage?: string; + navigateTo?: string; + onOptimistic?: () => void; + onRevert?: () => void; +} + +/** + * One move/undo implementation for list, thread, and opened-message actions. + */ +export default function useMailboxMove() { + const router = useRouter(); + const showToast = useToast(); + const [movingTo, setMovingTo] = useState(null); + + const moveEmails = useCallback( + async ({ + emails, + sourceMailboxId, + targetMailboxId, + successMessage, + failureMessage = "Could not move those messages.", + navigateTo, + onOptimistic, + onRevert, + }: MoveOptions) => { + if (!emails.length || movingTo) return false; + + setMovingTo(targetMailboxId); + onOptimistic?.(); + const movePromise = bulkMoveToMailbox(emails, targetMailboxId); + + showToast({ + message: successMessage, + actionLabel: "Undo", + onAction: async () => { + try { + await movePromise; + await bulkMoveToMailbox( + emails.map((email) => ({ + id: email.id, + mailboxIds: { [targetMailboxId]: true }, + })), + sourceMailboxId, + ); + onRevert?.(); + router.refresh(); + } catch { + showToast({ + message: "Could not undo that move.", + tone: "error", + }); + } + }, + }); + + try { + await movePromise; + if (navigateTo) router.replace(navigateTo); + router.refresh(); + return true; + } catch { + onRevert?.(); + showToast({ message: failureMessage, tone: "error" }); + return false; + } finally { + setMovingTo(null); + } + }, + [movingTo, router, showToast], + ); + + return { moveEmails, movingTo }; +} diff --git a/src/lib/__tests__/jmap.test.ts b/src/lib/__tests__/jmap.test.ts index 7816587..e0b11a4 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 { buildMailPanelMethodCalls, clearRecipientSuggestionCaches, deleteDraft, destroyAllEmailsInMailbox, destroyEmails, getAccountId, getContactsAccountId, getInboxSnapshot, getUnreadInboxTotal, listInboxEmails, loadMailPanelData, loadMoreEmailsFiltered, moveEmailsToMailbox, parseAddresses, saveDraft, searchContacts, searchRecipientSuggestions, sendEmail, setKeywordsOnMany } from "../jmap"; +import { buildMailPanelMethodCalls, clearRecipientSuggestionCaches, deleteDraft, destroyAllEmailsInMailbox, destroyEmails, getAccountId, getContactsAccountId, getEmailState, getUnreadInboxTotal, listInboxEmails, loadMailPanelData, loadMoreEmailsFiltered, moveEmailsToMailbox, parseAddresses, saveDraft, searchContacts, searchEmails, searchRecipientSuggestions, sendEmail, setKeywordsOnMany } from "../jmap"; const MAIL_CAP = "urn:ietf:params:jmap:mail"; @@ -318,21 +318,31 @@ describe("loadMailPanelData", () => { const primaryCalls = buildMailPanelMethodCalls( "acct1", { inbox: "inbox" }, - false, + { includeEmailState: true }, ); const deferredCalls = buildMailPanelMethodCalls("acct1", { drafts: "drafts", + pinned: "inbox", sent: "sent", spam: "spam", archive: "archive", trash: "trash", }); - assert.equal(primaryCalls.length, 4); + assert.equal(primaryCalls.length, 5); assert.equal(deferredCalls.length, 18); assert.ok( primaryCalls.every(([, , callId]) => callId !== "pq" && callId !== "pg"), ); + const pinnedQuery = deferredCalls.find(([, , callId]) => callId === "pq"); + assert.deepEqual(pinnedQuery?.[1].filter, { + inMailbox: "inbox", + hasKeyword: "$flagged", + }); + assert.equal( + primaryCalls.find(([, , callId]) => callId === "state")?.[0], + "Email/get", + ); }); it("loads all persistent mail-panel views in one JMAP request", async () => { @@ -370,6 +380,7 @@ describe("loadMailPanelData", () => { { inbox: "inbox", drafts: "drafts", + pinned: "inbox", sent: "sent", spam: "spam", archive: "archive", @@ -384,7 +395,8 @@ describe("loadMailPanelData", () => { ); assert.equal(result.inbox.unreadTotal, 2); assert.equal(result.inbox.readTotal, 7); - assert.equal(result.drafts[0].id, "draft"); + assert.equal(result.drafts.emails[0].id, "draft"); + assert.equal(result.drafts.total, 1); assert.equal(result.pinned[0].id, "pinned"); assert.equal(result.sent.total, 9); assert.equal(result.spam.unreadTotal, 3); @@ -482,27 +494,52 @@ describe("getUnreadInboxTotal", () => { }); }); -describe("getInboxSnapshot", () => { - it("uses one minimal query and returns the newest id with the inbox total", async () => { +describe("getEmailState", () => { + it("uses one minimal get and returns the account-wide Email state", async () => { capturedBodies = []; mockResponses = [ makeJmapResponse([ - ["Email/query", { ids: ["newest"], total: 83 }, "inbox-snapshot"], + ["Email/get", { list: [], state: "state-83" }, "email-state"], ]), ]; - const result = await getInboxSnapshot( + const result = await getEmailState( "https://api.example.com/jmap", "acct1", - "mbox1", ); const calls = (capturedBodies[0] as any).methodCalls; assert.equal(calls.length, 1); - assert.deepEqual(calls[0][1].filter, { inMailbox: "mbox1" }); - assert.equal(calls[0][1].limit, 1); - assert.equal(calls[0][1].calculateTotal, true); - assert.deepEqual(result, { latestEmailId: "newest", total: 83 }); + assert.equal(calls[0][0], "Email/get"); + assert.deepEqual(calls[0][1].ids, []); + assert.deepEqual(calls[0][1].properties, ["id"]); + assert.equal(result, "state-83"); + }); +}); + +describe("searchEmails", () => { + it("returns a total and passes the requested page position", async () => { + capturedBodies = []; + mockResponses = [ + makeJmapResponse([ + ["Email/query", { ids: ["match"], total: 81 }, "0"], + ["Email/get", { list: [makeEmailResponse("match", true)] }, "1"], + ]), + ]; + + const result = await searchEmails( + "https://api.example.com/jmap", + "acct1", + { text: "quarterly" }, + 50, + 50, + ); + const query = (capturedBodies[0] as any).methodCalls[0][1]; + + assert.equal(query.position, 50); + assert.equal(query.calculateTotal, true); + assert.equal(result.total, 81); + assert.equal(result.emails[0].id, "match"); }); }); diff --git a/src/lib/__tests__/mailAutoSync.test.ts b/src/lib/__tests__/mailAutoSync.test.ts index 9ce0198..1901f13 100644 --- a/src/lib/__tests__/mailAutoSync.test.ts +++ b/src/lib/__tests__/mailAutoSync.test.ts @@ -2,46 +2,15 @@ import assert from "node:assert/strict"; import test from "node:test"; import { canRunImmediateMailSync, - getInboxSnapshot, getMailAutoSyncDelay, - inboxSnapshotKey, + mailSyncSnapshotKey, } from "../mailAutoSync"; -import type { Email } from "../types"; -function email(id: string, receivedAt: string): Email { - return { - id, - messageId: [`${id}@example.test`], - threadId: `thread-${id}`, - mailboxIds: { inbox: true }, - subject: id, - from: null, - to: null, - cc: null, - replyTo: null, - inReplyTo: null, - receivedAt, - preview: "", - bodyValues: {}, - htmlBody: [], - textBody: [], - attachments: [], - hasAttachment: false, - keywords: {}, - size: 0, - }; -} - -test("builds a stable inbox snapshot from the newest loaded email and totals", () => { - const snapshot = getInboxSnapshot( - [email("newest", "2026-07-24T18:00:00.000Z")], - 4, - [email("older", "2026-07-24T17:00:00.000Z")], - 9, +test("uses the account-wide Email state as the stable sync key", () => { + assert.equal( + mailSyncSnapshotKey({ emailState: "state-42" }), + "state-42", ); - - assert.deepEqual(snapshot, { latestEmailId: "newest", total: 13 }); - assert.equal(inboxSnapshotKey(snapshot), "13:newest"); }); test("backs off failed background checks without exceeding two minutes", () => { diff --git a/src/lib/__tests__/mailbox.test.ts b/src/lib/__tests__/mailbox.test.ts new file mode 100644 index 0000000..aa2df92 --- /dev/null +++ b/src/lib/__tests__/mailbox.test.ts @@ -0,0 +1,73 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + getMailboxIds, + getMailboxViewForEmail, + getMailView, + isMailViewActive, + resolveMailboxes, +} from "../mailbox"; +import type { Mailbox } from "../types"; + +function mailbox( + id: string, + name: string, + role: string | null, +): Mailbox { + return { + id, + name, + role, + totalEmails: 0, + unreadEmails: 0, + parentId: null, + sortOrder: 0, + }; +} + +test("resolves all system mailbox roles in one place", () => { + const resolved = resolveMailboxes([ + mailbox("i", "Inbox", "inbox"), + mailbox("d", "Drafts", "drafts"), + mailbox("s", "Sent", "sent"), + mailbox("a", "Archive", "archive"), + mailbox("t", "Trash", "trash"), + mailbox("j", "Junk Mail", "junk"), + ]); + + assert.deepEqual(getMailboxIds(resolved), { + inbox: "i", + drafts: "d", + sent: "s", + archive: "a", + trash: "t", + spam: "j", + }); +}); + +test("falls back to a named Spam mailbox when the server omits its role", () => { + const resolved = resolveMailboxes([ + mailbox("custom-spam", "Spam", null), + ]); + assert.equal(resolved.spam?.id, "custom-spam"); +}); + +test("uses one view resolver for list, reader, and navigation routes", () => { + assert.equal(getMailView("/thread/t1", "archive"), "archive"); + assert.equal(getMailView("/email/e1", "trash"), "trash"); + assert.equal(getMailView("/sent"), "sent"); + assert.equal(getMailView("/email/e1"), "inbox"); + assert.equal(isMailViewActive("inbox", "/thread/t1", null), true); + assert.equal(isMailViewActive("inbox", "/thread/t1", "spam"), false); + assert.equal(isMailViewActive("spam", "/thread/t1", "spam"), true); +}); + +test("maps an email back to its system view using the shared mailbox ids", () => { + assert.equal( + getMailboxViewForEmail( + { "archive-id": true }, + { inbox: "inbox-id", archive: "archive-id" }, + ), + "archive", + ); +}); diff --git a/src/lib/jmap.ts b/src/lib/jmap.ts index 9b52947..a7d7376 100644 --- a/src/lib/jmap.ts +++ b/src/lib/jmap.ts @@ -101,12 +101,18 @@ const EMAIL_LIST_PROPERTIES = [ export interface MailPanelMailboxIds { inbox?: string; drafts?: string; + pinned?: string; sent?: string; spam?: string; archive?: string; trash?: string; } +export interface EmailPage { + emails: Email[]; + total: number; +} + export interface MailPanelData { inbox: { unreads: Email[]; @@ -114,9 +120,9 @@ export interface MailPanelData { reads: Email[]; readTotal: number; }; - drafts: Email[]; + drafts: EmailPage; pinned: Email[]; - sent: { emails: Email[]; total: number }; + sent: EmailPage; spam: { unreads: Email[]; unreadTotal: number; @@ -135,6 +141,7 @@ export interface MailPanelData { reads: Email[]; readTotal: number; }; + emailState?: string; } function mailboxQuery( @@ -178,7 +185,7 @@ function mailboxQuery( export function buildMailPanelMethodCalls( accountId: string, mailboxIds: MailPanelMailboxIds, - includePinned = true, + options: { includeEmailState?: boolean } = {}, ): MethodCall[] { const calls: MethodCall[] = []; @@ -198,13 +205,16 @@ export function buildMailPanelMethodCalls( ); } - if (includePinned) { + if (mailboxIds.pinned) { calls.push( [ "Email/query", { accountId, - filter: { hasKeyword: "$flagged" }, + filter: { + inMailbox: mailboxIds.pinned, + hasKeyword: "$flagged", + }, sort: [{ property: "receivedAt", isAscending: false }], limit: 100, position: 0, @@ -261,6 +271,18 @@ export function buildMailPanelMethodCalls( ); } + if (options.includeEmailState) { + calls.push([ + "Email/get", + { + accountId, + ids: [], + properties: ["id"], + }, + "state", + ]); + } + return calls; } @@ -273,13 +295,13 @@ export async function loadMailPanelData( apiUrl: string, accountId: string, mailboxIds: MailPanelMailboxIds, - includePinned = true, + options: { includeEmailState?: boolean } = {}, ): Promise { const startedAt = Date.now(); const methodCalls = buildMailPanelMethodCalls( accountId, mailboxIds, - includePinned, + options, ); const data = await jmapCall(apiUrl, methodCalls); const byCallId = new Map( @@ -297,7 +319,10 @@ export async function loadMailPanelData( reads: list("irg"), readTotal: total("irq"), }, - drafts: list("dg"), + drafts: { + emails: list("dg"), + total: total("dq"), + }, pinned: list("pg"), sent: { emails: list("sg"), @@ -321,6 +346,7 @@ export async function loadMailPanelData( reads: list("trg"), readTotal: total("trq"), }, + emailState: byCallId.get("state")?.state as string | undefined, }; log.info( @@ -355,19 +381,19 @@ const CALENDAR_CANDIDATE_PROPERTIES = [ "attachments", ]; -export async function listEmails( +async function queryEmailPage( apiUrl: string, accountId: string, - mailboxId: string, + filter: Record, + position = 0, limit = 50, - position = 0 -): Promise<{ emails: Email[]; total: number }> { +): Promise { const data = await jmapCall(apiUrl, [ [ "Email/query", { accountId, - filter: { inMailbox: mailboxId }, + filter, sort: [{ property: "receivedAt", isAscending: false }], calculateTotal: true, limit, @@ -393,6 +419,22 @@ export async function listEmails( }; } +export async function listEmails( + apiUrl: string, + accountId: string, + mailboxId: string, + limit = 50, + position = 0 +): Promise { + return queryEmailPage( + apiUrl, + accountId, + { inMailbox: mailboxId }, + position, + limit, + ); +} + /** * Fetch the inbox in two separate queries (unread + read) batched in one HTTP * request. Unreads always appear before reads in the combined result. @@ -473,39 +515,32 @@ export async function getUnreadInboxTotal( } /** - * Fetch the smallest useful inbox fingerprint for background synchronization. - * A changed newest id or total means the visible inbox needs a full refresh. + * Fetch the JMAP Email state token. It changes for message delivery, moves, + * keyword changes, draft changes, and sends, making it the single reliable + * cross-client synchronization fingerprint for every mail view. */ -export async function getInboxSnapshot( +export async function getEmailState( apiUrl: string, accountId: string, - mailboxId: string, -): Promise<{ latestEmailId: string | null; total: number }> { +): Promise { const data = await jmapCall( apiUrl, [[ - "Email/query", + "Email/get", { accountId, - filter: { inMailbox: mailboxId }, - sort: [{ property: "receivedAt", isAscending: false }], - calculateTotal: true, - limit: 1, - position: 0, + ids: [], + properties: ["id"], }, - "inbox-snapshot", + "email-state", ]], { logSuccess: false }, ); const [methodName, result] = data.methodResponses[0] ?? []; - if (methodName !== "Email/query") { - throw new Error("Unable to check for new inbox mail"); + if (methodName !== "Email/get" || typeof result.state !== "string") { + throw new Error("Unable to check mail state"); } - - return { - latestEmailId: ((result.ids as string[] | undefined) ?? [])[0] ?? null, - total: (result.total as number | undefined) ?? 0, - }; + return result.state; } export async function loadMoreEmailsFiltered( @@ -515,91 +550,28 @@ export async function loadMoreEmailsFiltered( filter: "unread" | "read", position: number, limit = 50 -): Promise<{ emails: Email[]; total: number }> { +): Promise { const jmapFilter = filter === "unread" ? { inMailbox: mailboxId, notKeyword: "$seen" } : { inMailbox: mailboxId, hasKeyword: "$seen" }; - const data = await jmapCall(apiUrl, [ - ["Email/query", { - accountId, - filter: jmapFilter, - sort: [{ property: "receivedAt", isAscending: false }], - calculateTotal: true, - limit, position, - }, "0"], - ["Email/get", { - accountId, - "#ids": { resultOf: "0", name: "Email/query", path: "/ids" }, - properties: EMAIL_LIST_PROPERTIES, - }, "1"], - ]); - const [, queryResult] = data.methodResponses[0]; - const [, getResult] = data.methodResponses[1]; - return { - emails: (getResult.list as Email[]) ?? [], - total: (queryResult.total as number) ?? 0, - }; -} - -export async function listPinnedEmails( - apiUrl: string, - accountId: string -): Promise { - const data = await jmapCall(apiUrl, [ - [ - "Email/query", - { - accountId, - filter: { hasKeyword: "$flagged" }, - sort: [{ property: "receivedAt", isAscending: false }], - limit: 100, - }, - "0", - ], - [ - "Email/get", - { - accountId, - "#ids": { resultOf: "0", name: "Email/query", path: "/ids" }, - properties: EMAIL_LIST_PROPERTIES, - }, - "1", - ], - ]); - const [, result] = data.methodResponses[1]; - return (result.list as Email[]) ?? []; + return queryEmailPage( + apiUrl, + accountId, + jmapFilter, + position, + limit, + ); } export async function searchEmails( apiUrl: string, accountId: string, filter: Record, - limit = 50 -): Promise { - const data = await jmapCall(apiUrl, [ - [ - "Email/query", - { - accountId, - filter, - sort: [{ property: "receivedAt", isAscending: false }], - limit, - }, - "0", - ], - [ - "Email/get", - { - accountId, - "#ids": { resultOf: "0", name: "Email/query", path: "/ids" }, - properties: EMAIL_LIST_PROPERTIES, - }, - "1", - ], - ]); - const [, result] = data.methodResponses[1]; - return (result.list as Email[]) ?? []; + limit = 50, + position = 0, +): Promise { + return queryEmailPage(apiUrl, accountId, filter, position, limit); } export async function getEmail( @@ -731,86 +703,6 @@ export async function setPin( ]); } -export async function listDrafts( - apiUrl: string, - accountId: string, - draftsMailboxId: string -): Promise { - const t = Date.now(); - const data = await jmapCall(apiUrl, [ - [ - "Email/query", - { - accountId, - filter: { inMailbox: draftsMailboxId }, - sort: [{ property: "receivedAt", isAscending: false }], - calculateTotal: true, - limit: 50, - }, - "0", - ], - [ - "Email/get", - { - accountId, - "#ids": { resultOf: "0", name: "Email/query", path: "/ids" }, - properties: EMAIL_LIST_PROPERTIES, - }, - "1", - ], - ]); - const [, qResult] = data.methodResponses[0]; - const [, gResult] = data.methodResponses[1]; - const emails = (gResult.list as Email[]) ?? []; - const total = (qResult.total as number) ?? 0; - log.info( - { mailbox_id: draftsMailboxId, count: emails.length, total, duration_ms: Date.now() - t }, - "jmap.list_drafts" - ); - return emails; -} - -export async function listSentEmails( - apiUrl: string, - accountId: string, - sentMailboxId: string, - limit = 50 -): Promise<{ emails: Email[]; total: number }> { - const t = Date.now(); - const data = await jmapCall(apiUrl, [ - [ - "Email/query", - { - accountId, - filter: { inMailbox: sentMailboxId }, - sort: [{ property: "receivedAt", isAscending: false }], - calculateTotal: true, - limit, - position: 0, - }, - "q", - ], - [ - "Email/get", - { - accountId, - "#ids": { resultOf: "q", name: "Email/query", path: "/ids" }, - properties: EMAIL_LIST_PROPERTIES, - }, - "g", - ], - ]); - const [, qResult] = data.methodResponses[0]; - const [, gResult] = data.methodResponses[1]; - const emails = (gResult.list as Email[]) ?? []; - const total = (qResult.total as number) ?? 0; - log.info( - { mailbox_id: sentMailboxId, count: emails.length, total, duration_ms: Date.now() - t }, - "jmap.list_sent" - ); - return { emails, total }; -} - export async function listRecentCalendarCandidateEmails( apiUrl: string, accountId: string, diff --git a/src/lib/mailAutoSync.ts b/src/lib/mailAutoSync.ts index febd2b4..c748290 100644 --- a/src/lib/mailAutoSync.ts +++ b/src/lib/mailAutoSync.ts @@ -1,34 +1,13 @@ -import type { Email } from "./types"; - export const MAIL_AUTO_SYNC_INTERVAL_MS = 15_000; export const MAIL_AUTO_SYNC_MAX_DELAY_MS = 120_000; export const MAIL_AUTO_SYNC_MIN_IMMEDIATE_GAP_MS = 5_000; -export interface InboxSnapshot { - latestEmailId: string | null; - total: number; -} - -export function getInboxSnapshot( - unreads: Email[], - unreadTotal: number, - reads: Email[], - readTotal: number, -): InboxSnapshot { - let latest: Email | undefined; - - for (const email of [...unreads, ...reads]) { - if (!latest || email.receivedAt > latest.receivedAt) latest = email; - } - - return { - latestEmailId: latest?.id ?? null, - total: unreadTotal + readTotal, - }; +export interface MailSyncSnapshot { + emailState: string; } -export function inboxSnapshotKey(snapshot: InboxSnapshot): string { - return `${snapshot.total}:${snapshot.latestEmailId ?? ""}`; +export function mailSyncSnapshotKey(snapshot: MailSyncSnapshot): string { + return snapshot.emailState; } export function getMailAutoSyncDelay( diff --git a/src/lib/mailbox.ts b/src/lib/mailbox.ts new file mode 100644 index 0000000..b7663c9 --- /dev/null +++ b/src/lib/mailbox.ts @@ -0,0 +1,116 @@ +import type { Mailbox } from "./types"; + +export const MAIL_VIEWS = [ + "inbox", + "drafts", + "sent", + "archive", + "trash", + "spam", +] as const; + +export type MailView = (typeof MAIL_VIEWS)[number]; +export type MailboxIds = Partial>; +export type ResolvedMailboxes = Partial>; + +export const MAIL_VIEW_PATHS: Record = { + inbox: "/", + drafts: "/drafts", + sent: "/sent", + archive: "/archive", + trash: "/trash", + spam: "/spam", +}; + +export const MAIL_VIEW_LABELS: Record = { + inbox: "Inbox", + drafts: "Drafts", + sent: "Sent", + archive: "Archive", + trash: "Trash", + spam: "Spam", +}; + +/** + * Resolve Fastmail's system mailboxes once. Spam is the only role whose + * server-side name varies, so its fallback lives here instead of at every + * call site. + */ +export function resolveMailboxes(mailboxes: Mailbox[]): ResolvedMailboxes { + const byRole = (role: string) => + mailboxes.find((mailbox) => mailbox.role === role); + + return { + inbox: byRole("inbox"), + drafts: byRole("drafts"), + sent: byRole("sent"), + archive: byRole("archive"), + trash: byRole("trash"), + spam: + byRole("junk") ?? + mailboxes.find((mailbox) => + ["spam", "junk"].includes(mailbox.name.toLowerCase()), + ), + }; +} + +export function getMailboxIds( + mailboxes: ResolvedMailboxes, +): MailboxIds { + return Object.fromEntries( + MAIL_VIEWS.flatMap((view) => { + const id = mailboxes[view]?.id; + return id ? [[view, id]] : []; + }), + ); +} + +export function getMailView( + pathname: string, + from?: string | null, +): MailView { + if (pathname.startsWith("/drafts")) return "drafts"; + if (pathname.startsWith("/sent") || from === "sent") return "sent"; + if (pathname.startsWith("/archive") || from === "archive") return "archive"; + if (pathname.startsWith("/trash") || from === "trash") return "trash"; + if (pathname.startsWith("/spam") || from === "spam") return "spam"; + return "inbox"; +} + +export function isMailViewActive( + view: MailView, + pathname: string, + from?: string | null, +): boolean { + if (view === "inbox") { + return ( + (pathname === "/" || + pathname.startsWith("/email/") || + pathname.startsWith("/thread/") || + pathname.startsWith("/attachment/")) && + getMailView(pathname, from) === "inbox" + ); + } + return getMailView(pathname, from) === view; +} + +export function getMailboxIdForView( + mailboxIds: MailboxIds, + view: MailView, +): string | undefined { + return mailboxIds[view]; +} + +export function isThreadMailView(view: MailView): boolean { + return !["drafts", "sent"].includes(view); +} + +export function getMailboxViewForEmail( + emailMailboxIds: Record, + mailboxIds: MailboxIds, +): MailView | undefined { + return MAIL_VIEWS.find((view) => { + const id = mailboxIds[view]; + return !!id && !!emailMailboxIds[id]; + }); +} diff --git a/tests/smoke/mail.spec.ts b/tests/smoke/mail.spec.ts index 67f0dec..96c4df1 100644 --- a/tests/smoke/mail.spec.ts +++ b/tests/smoke/mail.spec.ts @@ -261,6 +261,11 @@ test("aligns message actions at the iPhone 15 Pro viewport", async ({ (action) => action.height >= 44, ), ).toBe(true); + + await page.getByRole("button", { name: "More message actions" }).click(); + await expect( + page.getByRole("menuitem", { name: "Report Spam" }), + ).toBeVisible(); }); test("keeps the mobile navigation attached after viewport height changes", async ({ -- 2.51.2