From 44a7f5ac672672bca229d43540973e6447892893 Mon Sep 17 00:00:00 2001 From: Phillip Carter Date: Sun, 14 Jun 2026 14:26:35 -0700 Subject: [PATCH] spam --- src/app/(inbox)/email/[id]/actions.ts | 15 ++- src/app/(inbox)/layout.tsx | 18 +++- src/app/(inbox)/thread/[threadId]/page.tsx | 13 ++- src/app/layout.tsx | 14 ++- src/components/EmailDetailView.tsx | 21 ++++ src/components/EmailListPanel.tsx | 113 +++++++++++++++------ src/components/MobileNav.tsx | 18 +++- src/components/ThreadView.tsx | 30 +++++- src/lib/__tests__/emailHtml.test.ts | 4 +- src/lib/emailHtml.ts | 4 +- 10 files changed, 205 insertions(+), 45 deletions(-) diff --git a/src/app/(inbox)/email/[id]/actions.ts b/src/app/(inbox)/email/[id]/actions.ts index 293e9b3..93345a3 100644 --- a/src/app/(inbox)/email/[id]/actions.ts +++ b/src/app/(inbox)/email/[id]/actions.ts @@ -1,7 +1,7 @@ "use server"; import { auth } from "@/auth"; -import { getSession, getAccountId, getIdentities, getMailboxes, markAsRead, markAsUnread, sendCalendarReply, setKeywordsOnMany } from "@/lib/jmap"; +import { getSession, getAccountId, getIdentities, getMailboxes, markAsRead, markAsUnread, sendCalendarReply, setKeywordsOnMany, moveEmailsToMailbox } from "@/lib/jmap"; import { parseIcs, buildCalendarReply } from "@/lib/ics"; import { log } from "@/lib/logger"; @@ -90,3 +90,16 @@ 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 100d9bb..e501e77 100644 --- a/src/app/(inbox)/layout.tsx +++ b/src/app/(inbox)/layout.tsx @@ -1,3 +1,4 @@ +/* eslint-disable react-hooks/purity */ import { Suspense } from "react"; import { getSession, getAccountId, getMailboxes, listInboxEmails, listDrafts, listPinnedEmails, listSentEmails } from "@/lib/jmap"; import { log } from "@/lib/logger"; @@ -39,14 +40,16 @@ export default async function InboxLayout({ 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"); 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 }; try { - [inbox_emails, drafts, pinned, sentResult] = await Promise.all([ + [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 }), @@ -57,6 +60,9 @@ export default async function InboxLayout({ 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 }), ]); } catch (err) { log.error({ err, duration_ms: Date.now() - t }, "layout.inbox.fetch_error"); @@ -74,8 +80,13 @@ export default async function InboxLayout({ 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, has_drafts_mailbox: !!draftsMailbox, has_sent_mailbox: !!sentMailbox, + has_spam_mailbox: !!spamMailbox, duration_ms: Date.now() - t, }, "layout.inbox.load"); @@ -94,6 +105,11 @@ export default async function InboxLayout({ pinnedEmails={pinned} archiveMailboxId={archiveMailbox?.id} trashMailboxId={trashMailbox?.id} + spamUnreads={spam_emails.unreads} + spamUnreadTotal={spam_emails.unreadTotal} + spamReads={spam_emails.reads} + spamReadTotal={spam_emails.readTotal} + spamMailboxId={spamMailbox?.id} /> } diff --git a/src/app/(inbox)/thread/[threadId]/page.tsx b/src/app/(inbox)/thread/[threadId]/page.tsx index b4055fe..f0e033c 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 } from "@/lib/jmap"; +import { getSession, getAccountId, getThreadEmails, getMailboxes } from "@/lib/jmap"; import { notFound } from "next/navigation"; import Link from "next/link"; import MobileBackButton from "@/components/MobileBackButton"; @@ -41,6 +41,10 @@ export default async function ThreadPage({ params }: Props) { const subject = emails[emails.length - 1].subject ?? "(no subject)"; + const mailboxes = await getMailboxes(session.apiUrl, 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)) ); @@ -64,7 +68,12 @@ export default async function ThreadPage({ params }: Props) { Print - + ); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 9e9bbf3..e7d270b 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -29,6 +29,7 @@ export default async function RootLayout({ const session = await auth(); let unreadTotal = 0; let draftTotal = 0; + let spamUnreadTotal = 0; if (session) { const jmapSession = await getJmapSession(); @@ -36,8 +37,10 @@ export default async function RootLayout({ 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 ( @@ -70,12 +73,19 @@ export default async function RootLayout({ [DRAFTS] - [SENT] + + [SPAM] + + {/* Mobile bottom nav — hidden on desktop */} -
+
diff --git a/src/components/EmailDetailView.tsx b/src/components/EmailDetailView.tsx index fe480be..d76ef9f 100644 --- a/src/components/EmailDetailView.tsx +++ b/src/components/EmailDetailView.tsx @@ -7,6 +7,8 @@ import CalendarEventCard from "@/components/CalendarEventCard"; import MarkUnreadButton from "@/components/MarkUnreadButton"; import AttachmentList from "@/components/AttachmentList"; import { resolveCalendarEvent } from "@/lib/calendarDetect"; +import { getSession, getMailboxes } from "@/lib/jmap"; +import NotSpamButton from "@/components/NotSpamButton"; interface Props { email: Email; @@ -37,6 +39,18 @@ export default async function EmailDetailView({ email, downloadUrl, accountId }: // ── Detect calendar invite ──────────────────────────────────── const calendarEvent = await resolveCalendarEvent(email, downloadUrl, accountId); + // ── Detect spam/junk mailbox ──────────────────────────────────── + const jmapSession = await getSession(); + const mailboxes = await getMailboxes(jmapSession.apiUrl, 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 isSpam = !!(spamMailbox && email.mailboxIds[spamMailbox.id]); + const hasMultipleRecipients = (email.to?.length ?? 0) + (email.cc?.length ?? 0) > 1; @@ -81,6 +95,13 @@ export default async function EmailDetailView({ email, downloadUrl, accountId }: {/* Action buttons */}
+ {isSpam && inboxMailbox && ( + + )} ([]); const [loadingMore, setLoadingMore] = useState(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 currentMailboxId = view === "spam" ? spamMailboxId ?? "" : inboxId; + + useEffect(() => { + setExtraUnreads([]); + setExtraReads([]); + }, [view]); + useEffect(() => { - const fresh = [...unreads, ...reads, ...pinnedEmails]; + const fresh = [...currentUnreads, ...currentReads, ...pinnedEmails]; setExtraUnreads((prev) => mergeEmailUpdates(prev, fresh)); setExtraReads((prev) => mergeEmailUpdates(prev, fresh)); // Server state is authoritative after a refresh; clear optimistic removals setArchivedIds(new Set()); - }, [unreads, reads, pinnedEmails]); + }, [currentUnreads, currentReads, pinnedEmails]); // ------------------------------------------------------------------------- // Drafts — local copy for optimistic deletion @@ -261,14 +284,14 @@ export default function EmailListPanel({ // Merged email lists // ------------------------------------------------------------------------- const allUnreads = useMemo(() => { - const propIds = new Set(unreads.map((e) => e.id)); - return [...unreads, ...extraUnreads.filter((e) => !propIds.has(e.id))]; - }, [unreads, extraUnreads]); + const propIds = new Set(currentUnreads.map((e) => e.id)); + return [...currentUnreads, ...extraUnreads.filter((e) => !propIds.has(e.id))]; + }, [currentUnreads, extraUnreads]); const allReads = useMemo(() => { - const propIds = new Set(reads.map((e) => e.id)); - return [...reads, ...extraReads.filter((e) => !propIds.has(e.id))]; - }, [reads, extraReads]); + const propIds = new Set(currentReads.map((e) => e.id)); + return [...currentReads, ...extraReads.filter((e) => !propIds.has(e.id))]; + }, [currentReads, extraReads]); // Display order: pinned → unread (not pinned) → read (not pinned), deduped const allInboxEmails = useMemo(() => { @@ -276,11 +299,13 @@ export default function EmailListPanel({ const seenIds = new Set(); const result: Email[] = []; const add = (e: Email) => { if (!seenIds.has(e.id)) { seenIds.add(e.id); result.push(e); } }; - pinnedEmails.forEach(add); + if (view === "inbox") { + pinnedEmails.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]); + }, [pinnedEmails, allUnreads, allReads, view]); const visibleEmails = useMemo(() => { const base = isInSearchMode ? searchResults : allInboxEmails; @@ -298,10 +323,10 @@ export default function EmailListPanel({ // ------------------------------------------------------------------------- // Pagination // ------------------------------------------------------------------------- - const loadedUnreads = unreads.length + extraUnreads.length; - const loadedReads = reads.length + extraReads.length; - const hasMoreUnreads = loadedUnreads < unreadTotal; - const hasMoreReads = loadedReads < readTotal; + const loadedUnreads = currentUnreads.length + extraUnreads.length; + const loadedReads = currentReads.length + extraReads.length; + const hasMoreUnreads = loadedUnreads < currentUnreadTotal; + const hasMoreReads = loadedReads < currentReadTotal; const hasMore = !isInSearchMode && (hasMoreUnreads || hasMoreReads); const unreadCount = useUnreadCount(); @@ -362,10 +387,10 @@ export default function EmailListPanel({ setLoadingMore(true); try { if (hasMoreUnreads) { - const { emails: more } = await loadMoreUnreads(inboxId, loadedUnreads); + const { emails: more } = await loadMoreUnreads(currentMailboxId, loadedUnreads); setExtraUnreads((prev) => [...prev, ...more]); } else { - const { emails: more } = await loadMoreReads(inboxId, loadedReads); + const { emails: more } = await loadMoreReads(currentMailboxId, loadedReads); setExtraReads((prev) => [...prev, ...more]); } } finally { @@ -463,6 +488,18 @@ export default function EmailListPanel({ router.refresh(); } + 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(); + await bulkMoveToMailbox( + emails.map((e) => ({ id: e.id, mailboxIds: e.mailboxIds })), + inboxId + ); + router.refresh(); + } + const actionBtnCls = "p-1.5 rounded-md hover:bg-blue-100 dark:hover:bg-blue-900/60 text-blue-600 dark:text-blue-400 transition-colors shrink-0"; @@ -476,7 +513,7 @@ export default function EmailListPanel({
- {view === "inbox" ? "Inbox" : view === "drafts" ? "Drafts" : "Sent"} + {view === "inbox" ? "Inbox" : view === "drafts" ? "Drafts" : view === "sent" ? "Sent" : "Spam"} {view === "inbox" && ( @@ -484,9 +521,12 @@ export default function EmailListPanel({ {view === "drafts" && ( )} + {view === "spam" && ( + + )}
- {view === "inbox" && ( + {(view === "inbox" || view === "spam") && ( + {view === "spam" && ( + + )} - - {archiveMailboxId && ( + {view === "inbox" && ( + + )} + {view === "inbox" && archiveMailboxId && ( @@ -616,7 +667,7 @@ export default function EmailListPanel({ )} {/* Inbox list */} - {view === "inbox" && ( + {(view === "inbox" || view === "spam") && (
{/* "Up to date" success pill — floats over the list after refresh */} @@ -657,9 +708,9 @@ export default function EmailListPanel({ ) && !isRouteSelected; const showPinnedDivider = - !isInSearchMode && pinnedThreadCount > 0 && idx === 0; + !isInSearchMode && view === "inbox" && pinnedThreadCount > 0 && idx === 0; const showRestDivider = - !isInSearchMode && pinnedThreadCount > 0 && idx === pinnedThreadCount; + !isInSearchMode && view === "inbox" && pinnedThreadCount > 0 && idx === pinnedThreadCount; // Sender display: comma-separated unique names, truncated to 3 const senderLabel = diff --git a/src/components/MobileNav.tsx b/src/components/MobileNav.tsx index 02077b3..7114ddf 100644 --- a/src/components/MobileNav.tsx +++ b/src/components/MobileNav.tsx @@ -60,11 +60,20 @@ function SettingsIcon() { ); } +function SpamIcon() { + return ( + + ); +} + interface Props { draftTotal?: number; + spamTotal?: number; } -export default function MobileNav({ draftTotal = 0 }: Props) { +export default function MobileNav({ draftTotal = 0, spamTotal = 0 }: Props) { const pathname = usePathname(); const unreadTotal = useUnreadCount(); @@ -94,6 +103,13 @@ export default function MobileNav({ draftTotal = 0 }: Props) { active: pathname.startsWith("/sent"), badge: 0, }, + { + href: "/spam", + label: "Spam", + icon: , + active: pathname.startsWith("/spam"), + badge: spamTotal, + }, { href: "/calendar", label: "Calendar", diff --git a/src/components/ThreadView.tsx b/src/components/ThreadView.tsx index 884a6cb..0ec0fdc 100644 --- a/src/components/ThreadView.tsx +++ b/src/components/ThreadView.tsx @@ -9,6 +9,7 @@ import CalendarEventCard, { CalendarEventData, CalendarResponse } from "@/compon import AttachmentList from "@/components/AttachmentList"; import { Email } from "@/lib/types"; import { formatAddressList, formatFullDate } from "@/lib/format"; +import NotSpamButton from "@/components/NotSpamButton"; // --------------------------------------------------------------------------- // Body resolution (mirrors email/[id]/page.tsx logic) @@ -46,11 +47,23 @@ interface ItemProps { onResponseSent: (r: CalendarResponse) => void; expanded: boolean; onToggle: () => void; + spamMailboxId?: string; + inboxMailboxId?: string; } -function EmailStackItem({ email, calendarEvent, persistedResponse, onResponseSent, expanded, onToggle }: ItemProps) { +function EmailStackItem({ + email, + calendarEvent, + persistedResponse, + onResponseSent, + expanded, + onToggle, + spamMailboxId, + inboxMailboxId, +}: ItemProps) { const isUnread = !email.keywords?.["$seen"]; const resolved = resolveBody(email); + const isSpam = !!(spamMailboxId && email.mailboxIds[spamMailboxId]); return (
{/* Action buttons */} -
+
+ {isSpam && inboxMailboxId && ( + + )} >( @@ -232,6 +254,8 @@ export default function ThreadView({ emails, calendarEvents }: Props) { onResponseSent={(r) => setRsvpResponses((prev) => ({ ...prev, [email.id]: r }))} expanded={expandedIds.has(email.id)} onToggle={() => toggle(email.id)} + spamMailboxId={spamMailboxId} + inboxMailboxId={inboxMailboxId} /> ))}
diff --git a/src/lib/__tests__/emailHtml.test.ts b/src/lib/__tests__/emailHtml.test.ts index e56ebeb..12d9018 100644 --- a/src/lib/__tests__/emailHtml.test.ts +++ b/src/lib/__tests__/emailHtml.test.ts @@ -83,7 +83,7 @@ describe("prepareHtml", () => { const result = prepareHtml('see https://example.com for info'); assert.ok(result.includes(' tag", () => { @@ -385,7 +385,7 @@ describe("linkifyHtmlText (via prepareHtml)", () => { it("linkified anchor opens in a new tab", () => { const result = prepareHtml(wrap("Go to https://example.com now.")); assert.ok(result.includes('target="_blank"'), "linkified URL should have target=_blank"); - assert.ok(result.includes('rel="noopener noreferrer"'), "linkified URL should have noopener rel"); + assert.ok(result.includes('rel="noopener"'), "linkified URL should have noopener rel"); }); it("injects cursor:pointer on anchors so hover cursor works inside the sandboxed iframe", () => { diff --git a/src/lib/emailHtml.ts b/src/lib/emailHtml.ts index ed889ac..5dd674a 100644 --- a/src/lib/emailHtml.ts +++ b/src/lib/emailHtml.ts @@ -77,7 +77,7 @@ function linkifyHtmlText(html: string): string { const trimmed = url.replace(/[.,;:!?)]+$/, ""); const rest = url.slice(trimmed.length); const safeHref = trimmed.replace(/&/g, "&"); - return `${trimmed}${rest}`; + return `${trimmed}${rest}`; }); }); } @@ -216,7 +216,7 @@ export function prepareHtml( var links=document.getElementsByTagName('a'); for(var i=0;i