This commit is contained in:
Linus Rath
2026-07-07 20:36:05 +02:00
44 changed files with 565 additions and 86 deletions
+36 -15
View File
@@ -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 && (
<ContextMenuItem
icon={isPinned ? PinOff : Pin}
label={isPinned ? t("unpin") : t("pin")}
onClick={() => handleAction(onTogglePinned)}
/>
)}
{/* Set tag submenu - only for single email */}
{!showBatchActions && (
<ContextMenuSubMenu icon={Tag} label={t("color_tag")}>
@@ -385,22 +402,26 @@ export function EmailContextMenu({
</ContextMenuSubMenu>
)}
<ContextMenuSeparator />
{/* Spam - contextual based on folder; pointless on own outgoing mail */}
{spamApplicable && (
<>
<ContextMenuSeparator />
{/* Spam - contextual based on folder */}
<ContextMenuItem
icon={isInJunkFolder ? ShieldCheck : ShieldAlert}
label={isInJunkFolder ? t("not_spam") : t("mark_as_spam")}
onClick={() =>
handleAction(
showBatchActions
? (isInJunkFolder ? onBatchUndoSpam! : onBatchMarkAsSpam!)
: (isInJunkFolder ? onUndoSpam! : onMarkAsSpam!)
)
}
disabled={showBatchActions ? (isInJunkFolder ? !onBatchUndoSpam : !onBatchMarkAsSpam) : (isInJunkFolder ? !onUndoSpam : !onMarkAsSpam)}
destructive={!isInJunkFolder}
/>
<ContextMenuItem
icon={isInJunkFolder ? ShieldCheck : ShieldAlert}
label={isInJunkFolder ? t("not_spam") : t("mark_as_spam")}
onClick={() =>
handleAction(
showBatchActions
? (isInJunkFolder ? onBatchUndoSpam! : onBatchMarkAsSpam!)
: (isInJunkFolder ? onUndoSpam! : onMarkAsSpam!)
)
}
disabled={showBatchActions ? (isInJunkFolder ? !onBatchUndoSpam : !onBatchMarkAsSpam) : (isInJunkFolder ? !onUndoSpam : !onMarkAsSpam)}
destructive={!isInJunkFolder}
/>
</>
)}
<ContextMenuSeparator />
+4
View File
@@ -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<HoverAction, {
@@ -78,6 +80,7 @@ export function EmailHoverActions({
onMarkAsSpam,
isInJunk = false,
onUndoSpam,
spamApplicable = true,
}: EmailHoverActionsProps) {
const hoverActions = useSettingsStore((state) => 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".
+7 -1
View File
@@ -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
</div>
</div>
<div className="flex items-center gap-2.5 shrink-0">
{isPinned && <Pin className="w-3.5 h-3.5 text-primary" />}
{isStarred && <Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />}
{isImportant && <span className="h-2 w-2 rounded-full bg-warning" />}
{isAnswered && !isForwarded && <Reply className="w-3.5 h-3.5 text-muted-foreground" />}
@@ -253,6 +255,9 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
{sender?.name || sender?.email || "Unknown"}
</span>
<div className="flex items-center gap-1.5">
{isPinned && (
<Pin className="w-3.5 h-3.5 text-primary" />
)}
{isStarred && (
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
)}
@@ -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 || '')}
/>
</div>
);
+3
View File
@@ -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)}
+25 -2
View File
@@ -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({
</div>
{/* Spam */}
{(onMarkAsSpam || onUndoSpam) && (
{spamApplicable && (onMarkAsSpam || onUndoSpam) && (
<Button
variant="ghost"
size="sm"
@@ -3136,7 +3159,7 @@ export function EmailViewer({
</div>
)}
{/* Overflow: spam */}
{(onMarkAsSpam || onUndoSpam) && (
{spamApplicable && (onMarkAsSpam || onUndoSpam) && (
<button
onClick={() => { (isInJunkFolder ? onUndoSpam : onMarkAsSpam)?.(); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className={cn("w-full px-3 py-1.5 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2", hiddenPriorities.has(7) ? "" : "sm:hidden")}
+13 -2
View File
@@ -5,7 +5,7 @@ import { formatDate, formatDateTime, stripInvisibleLeading } from "@/lib/utils";
import { Email, ThreadGroup, ALL_MAIL_MAILBOX_ID } from "@/lib/jmap/types";
import { cn } from "@/lib/utils";
import { SelectableAvatar } from "@/components/email/selectable-avatar";
import { Paperclip, Star, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare, CheckSquare, Square, Reply, Forward, CalendarClock, Folder } from "lucide-react";
import { Paperclip, Star, Pin, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare, CheckSquare, Square, Reply, Forward, CalendarClock, Folder } from "lucide-react";
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
import { useUIStore } from "@/stores/ui-store";
import { useEmailStore } from "@/stores/email-store";
@@ -78,6 +78,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
const tBatch = useTranslations('email_list.batch_actions');
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
const isPinned = email.keywords?.['$pinned'] === true;
const isAnswered = email.keywords?.$answered;
const isForwarded = email.keywords?.$forwarded;
const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView, unifiedRole } = useEmailStore();
@@ -264,6 +265,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
</div>
</div>
<div className="flex items-center gap-2.5 shrink-0">
{isPinned && <Pin className="w-3.5 h-3.5 text-primary" />}
{isStarred && <Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />}
{isAnswered && !isForwarded && <Reply className="w-3.5 h-3.5 text-muted-foreground" />}
{isForwarded && !isAnswered && <Forward className="w-3.5 h-3.5 text-muted-foreground" />}
@@ -316,6 +318,9 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
{sender?.name || sender?.email || "Unknown"}
</span>
<div className="flex items-center gap-1.5">
{isPinned && (
<Pin className="w-3.5 h-3.5 text-primary" />
)}
{isStarred && (
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
)}
@@ -405,6 +410,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
onMarkAsSpam={onMarkAsSpam}
onUndoSpam={onUndoSpam}
isInJunk={currentMailboxRole === 'junk'}
spamApplicable={!['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || '')}
/>
)}
</div>
@@ -442,7 +448,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
const timeFormat = useSettingsStore((state) => state.timeFormat);
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
const isMobile = useUIStore((state) => state.isMobile);
const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread;
const { latestEmail, participantNames, hasUnread, hasStarred, hasPinned, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread;
// The horizontal one-line "focus" layout doesn't fit on narrow screens; fall back to multi-line on mobile.
const isFocusedMailLayout = mailLayout === 'focus' && !isMobile;
const trimmedPreview = stripInvisibleLeading(latestEmail.preview ?? '');
@@ -722,6 +728,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
</div>
</div>
<div className="flex items-center gap-2.5 shrink-0">
{hasPinned && <Pin className="w-3.5 h-3.5 text-primary" />}
{hasStarred && <Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />}
{hasAnswered && !hasForwarded && <Reply className="w-3.5 h-3.5 text-muted-foreground" />}
{hasForwarded && !hasAnswered && <Forward className="w-3.5 h-3.5 text-muted-foreground" />}
@@ -786,6 +793,9 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
{emailCount}
</span>
<div className="flex items-center gap-1.5">
{hasPinned && (
<Pin className="w-3.5 h-3.5 text-primary" />
)}
{hasStarred && (
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
)}
@@ -875,6 +885,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined}
onUndoSpam={onUndoSpam ? () => onUndoSpam(latestEmail) : undefined}
isInJunk={currentMailboxRole === 'junk'}
spamApplicable={!['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || '')}
/>
)}
</div>