diff --git a/app/(main)/[locale]/page.tsx b/app/(main)/[locale]/page.tsx index 67699187..04d8b8b9 100644 --- a/app/(main)/[locale]/page.tsx +++ b/app/(main)/[locale]/page.tsx @@ -621,6 +621,10 @@ export default function Home() { onToggleSpam: async () => { if (isScheduledView) return; const currentMailbox = mailboxes.find(m => m.id === selectedMailbox); + // Marking your own outgoing mail as spam makes no sense - the toolbar + // and menus hide the action in Sent/Drafts/Scheduled, so the shortcut + // is a no-op there too. + if (['sent', 'drafts', 'scheduled'].includes(currentMailbox?.role || '')) return; const isInJunk = currentMailbox?.role === 'junk'; if (selectedEmailIds.size > 0 && client) { const ids = Array.from(selectedEmailIds); @@ -1648,6 +1652,45 @@ export default function Home() { } }; + const handleTogglePinned = async (emailToPin: Email) => { + if (!client) return; + + try { + const email = emails.find(e => e.id === emailToPin.id) ?? emailToPin; + const isPinned = email.keywords?.['$pinned'] === true; + // JMAP keywords are a set of present keys - drop the key to unpin + // rather than writing a false value. + const keywords = { ...email.keywords }; + if (isPinned) { + delete keywords['$pinned']; + } else { + keywords['$pinned'] = true; + } + + // Same unified-view routing as color tags: write to the email's own + // account via the login it is reachable through. (#281) + const pinClientId = isUnifiedView ? email.sourceClientAccountId : undefined; + const pinAccountId = isUnifiedView ? email.sourceAccountId : undefined; + const pinClient = pinClientId + ? (useAuthStore.getState().getClientForAccount(pinClientId) ?? client) + : client; + + await pinClient.updateEmailKeywords(email.id, keywords, pinAccountId); + + // Patch in place so the icon flips immediately, then refetch the first + // page so the mail floats/sinks per the server's pinned-first sort. + // Skip the refetch where that sort does not apply (unified views) or + // where it would replace a tag-filtered list (refreshCurrentMailbox + // fetches by folder only). + setEmailKeywordsLocal(email.id, keywords); + if (!isUnifiedView && !useEmailStore.getState().selectedKeyword) { + void refreshCurrentMailbox(client); + } + } catch (error) { + console.error("Failed to toggle pin:", error); + } + }; + const handleSetColorTag = async (emailId: string, color: string | null) => { if (!client) return; @@ -3038,6 +3081,9 @@ export default function Home() { await toggleStar(client, email.id); } }} + onTogglePinned={async (email) => { + await handleTogglePinned(email); + }} onDelete={async (email) => { await handleDelete(email); }} diff --git a/app/api/auth/token/route.ts b/app/api/auth/token/route.ts index e734ba71..16768956 100644 --- a/app/api/auth/token/route.ts +++ b/app/api/auth/token/route.ts @@ -82,9 +82,16 @@ export async function PUT(request: NextRequest) { if (!tokenResponse.ok) { const errorText = await tokenResponse.text(); logger.error('Token refresh failed', { status: tokenResponse.status, error: errorText }); - cookieStore.delete(cookieName); - cookieStore.delete(refreshTokenServerCookieName(slot)); - return NextResponse.json({ error: 'Refresh failed' }, { status: 401 }); + // Drop the refresh token only when the server definitively rejected it + // (invalid/expired/revoked grant). A 5xx or 429 is an outage - keeping + // the cookie lets the session resume once the server is back. + const status = tokenResponse.status; + if (status === 400 || status === 401 || status === 403) { + cookieStore.delete(cookieName); + cookieStore.delete(refreshTokenServerCookieName(slot)); + return NextResponse.json({ error: 'Refresh failed' }, { status: 401 }); + } + return NextResponse.json({ error: 'Token endpoint unavailable' }, { status: 503 }); } const tokens = await tokenResponse.json(); diff --git a/components/email/email-context-menu.tsx b/components/email/email-context-menu.tsx index 83a4b4de..9a66f9b6 100644 --- a/components/email/email-context-menu.tsx +++ b/components/email/email-context-menu.tsx @@ -17,6 +17,8 @@ import { Mail, MailOpen, Star, + Pin, + PinOff, Trash2, Archive, FolderInput, @@ -59,6 +61,7 @@ interface EmailContextMenuProps { onForward?: () => void; onMarkAsRead?: (read: boolean) => void; onToggleStar?: () => void; + onTogglePinned?: () => void; onDelete?: () => void; onArchive?: () => void; onSetColorTag?: (color: string | null) => void; @@ -126,6 +129,7 @@ export function EmailContextMenu({ onForward, onMarkAsRead, onToggleStar, + onTogglePinned, onDelete, onArchive, onSetColorTag, @@ -149,10 +153,14 @@ export function EmailContextMenu({ const emailKeywords = useSettingsStore((state) => state.emailKeywords); const isUnread = !email.keywords?.$seen; const isStarred = email.keywords?.$flagged; + const isPinned = email.keywords?.['$pinned'] === true; const isDraft = email.keywords?.['$draft'] === true; const currentColors = getCurrentColors(email.keywords); const showBatchActions = isMultiSelect && selectedCount > 1; const isInJunkFolder = currentMailboxRole === 'junk'; + // Marking your own outgoing mail as spam makes no sense - hide the action + // in Sent, Drafts and Scheduled. + const spamApplicable = !['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || ''); const isScheduled = email.isScheduled === true; const canCancelScheduled = isScheduled && email.scheduledUndoStatus === 'pending'; @@ -349,6 +357,15 @@ export function EmailContextMenu({ /> )} + {/* Pin/Unpin - only for single email; pinned mails float to the top of the list */} + {!showBatchActions && onTogglePinned && ( + handleAction(onTogglePinned)} + /> + )} + {/* Set tag submenu - only for single email */} {!showBatchActions && ( @@ -385,22 +402,26 @@ export function EmailContextMenu({ )} - + {/* Spam - contextual based on folder; pointless on own outgoing mail */} + {spamApplicable && ( + <> + - {/* Spam - contextual based on folder */} - - handleAction( - showBatchActions - ? (isInJunkFolder ? onBatchUndoSpam! : onBatchMarkAsSpam!) - : (isInJunkFolder ? onUndoSpam! : onMarkAsSpam!) - ) - } - disabled={showBatchActions ? (isInJunkFolder ? !onBatchUndoSpam : !onBatchMarkAsSpam) : (isInJunkFolder ? !onUndoSpam : !onMarkAsSpam)} - destructive={!isInJunkFolder} - /> + + handleAction( + showBatchActions + ? (isInJunkFolder ? onBatchUndoSpam! : onBatchMarkAsSpam!) + : (isInJunkFolder ? onUndoSpam! : onMarkAsSpam!) + ) + } + disabled={showBatchActions ? (isInJunkFolder ? !onBatchUndoSpam : !onBatchMarkAsSpam) : (isInJunkFolder ? !onUndoSpam : !onMarkAsSpam)} + destructive={!isInJunkFolder} + /> + + )} diff --git a/components/email/email-hover-actions.tsx b/components/email/email-hover-actions.tsx index 4e465fe7..7bb4a734 100644 --- a/components/email/email-hover-actions.tsx +++ b/components/email/email-hover-actions.tsx @@ -21,6 +21,8 @@ interface EmailHoverActionsProps { // the spam quick-action flips to "not spam". isInJunk?: boolean; onUndoSpam?: () => void; + // Hidden where marking spam is meaningless for self-authored mail (Drafts, Sent). + spamApplicable?: boolean; } const ACTION_CONFIG: Record state.hoverActions); const hoverActionsMode = useSettingsStore((state) => state.hoverActionsMode); @@ -121,6 +124,7 @@ export function EmailHoverActions({ const actionButtons = hoverActions.map((actionId) => { const config = ACTION_CONFIG[actionId]; if (!config) return null; + if (actionId === "spam" && !spamApplicable) return null; const Icon = config.icon; // In a junk context the spam action becomes "not spam". diff --git a/components/email/email-list-item.tsx b/components/email/email-list-item.tsx index 27fb5c64..b29819fe 100644 --- a/components/email/email-list-item.tsx +++ b/components/email/email-list-item.tsx @@ -6,7 +6,7 @@ import { formatDate, stripInvisibleLeading } from "@/lib/utils"; import { Email } from "@/lib/jmap/types"; import { cn } from "@/lib/utils"; import { SelectableAvatar } from "@/components/email/selectable-avatar"; -import { Paperclip, Star, Circle, CheckSquare, Square, Reply, Forward } from "lucide-react"; +import { Paperclip, Star, Pin, Circle, CheckSquare, Square, Reply, Forward } from "lucide-react"; import { useEmailStore } from "@/stores/email-store"; import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store"; import { useAuthStore } from "@/stores/auth-store"; @@ -45,6 +45,7 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte const isChecked = selectedEmailIds.has(email.id); const isUnread = !email.keywords?.$seen; const isStarred = email.keywords?.$flagged; + const isPinned = email.keywords?.['$pinned'] === true; const isImportant = email.keywords?.["$important"]; const isAnswered = email.keywords?.$answered; const isForwarded = email.keywords?.$forwarded; @@ -217,6 +218,7 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
+ {isPinned && } {isStarred && } {isImportant && } {isAnswered && !isForwarded && } @@ -253,6 +255,9 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte {sender?.name || sender?.email || "Unknown"}
+ {isPinned && ( + + )} {isStarred && ( )} @@ -338,6 +343,7 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte onMarkAsSpam={onMarkAsSpam} onUndoSpam={onUndoSpam} isInJunk={currentMailboxRole === 'junk'} + spamApplicable={!['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || '')} />
); diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index 9c3ec0c7..cab95c82 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -35,6 +35,7 @@ interface EmailListProps { onForward?: (email: Email) => void; onMarkAsRead?: (email: Email, read: boolean) => void; onToggleStar?: (email: Email) => void; + onTogglePinned?: (email: Email) => void; onDelete?: (email: Email) => void; onArchive?: (email: Email) => void; onSetColorTag?: (emailId: string, color: string | null) => void; @@ -64,6 +65,7 @@ export function EmailList({ onForward, onMarkAsRead, onToggleStar, + onTogglePinned, onDelete, onArchive, onSetColorTag, @@ -551,6 +553,7 @@ export function EmailList({ onForward={() => onForward?.(contextMenu.data!)} onMarkAsRead={(read) => onMarkAsRead?.(contextMenu.data!, read)} onToggleStar={() => onToggleStar?.(contextMenu.data!)} + onTogglePinned={onTogglePinned ? () => onTogglePinned(contextMenu.data!) : undefined} onDelete={() => onDelete?.(contextMenu.data!)} onArchive={() => onArchive?.(contextMenu.data!)} onSetColorTag={(color) => onSetColorTag?.(contextMenu.data!.id, color)} diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index ccc7b778..76ec3c50 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -693,6 +693,9 @@ export function EmailViewer({ // Detect if current mailbox is Junk folder const isInJunkFolder = currentMailboxRole === 'junk'; + // Marking your own outgoing mail as spam makes no sense - hide the action + // in Sent, Drafts and Scheduled. + const spamApplicable = !['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || ''); // Detect if the email is a draft const isDraft = email?.keywords?.['$draft'] === true; @@ -2172,6 +2175,26 @@ export function EmailViewer({ lastBodyHeightRef.current = initialHeight; setIframeReady(true); + // Hide images that fail to load (dead/mixed-content/unreachable external + // URLs) rather than leaving the browser's broken-image placeholder and + // alt text, which read as stray label text in an otherwise image-only + // email (e.g. a blocked "logo" alt). Blocked images already carry a 1x1 + // transparent pixel (naturalWidth 1) and display:none, so they're skipped. + const hideIfBroken = (img: HTMLImageElement) => { + if (img.complete && img.naturalWidth === 0 && img.getAttribute('src')) { + img.style.display = 'none'; + } + }; + doc.querySelectorAll('img').forEach((el) => { + const img = el as HTMLImageElement; + if (img.complete) { + hideIfBroken(img); + } else { + img.addEventListener('error', () => { img.style.display = 'none'; }, { once: true }); + img.addEventListener('load', () => hideIfBroken(img), { once: true }); + } + }); + // Make links open in new tab doc.querySelectorAll('a').forEach(a => { a.setAttribute('target', '_blank'); @@ -2902,7 +2925,7 @@ export function EmailViewer({
{/* Spam */} - {(onMarkAsSpam || onUndoSpam) && ( + {spamApplicable && (onMarkAsSpam || onUndoSpam) && (