Merge branch 'main' of https://github.com/bulwarkmail/webmail
This commit is contained in:
@@ -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 />
|
||||
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user