mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 00:14:23 +00:00
Stalwart does no BIMI, so sender icons were only ever favicons, which anyone controls for any domain. The webmail server now does the receiver side itself at /api/bimi, and avatars ask it only for messages whose own Authentication-Results say dmarc=pass for the exact From domain. - `lib/bimi/`: DMARC policy check (quarantine or reject, pct=100, the parent domain's sp and p for subdomains), the assertion record with fallback to the parent domain, mark certificate validation (chain to pinned DigiCert, GlobalSign and SSL.com VMC roots, BIMI key purpose, validity, SAN, SCTs present, CRL revocation, logo from the logotype extension), SVG Tiny PS checks and rendering to PNG, caching - logos backed only by the DNS record show by default; the admin setting "Require a mark certificate" (`BIMI_REQUIRE_CERTIFICATE`) limits them to VMC and CMC backed ones - a check mark beside the sender's name for certificate-backed logos - avatars try contact photo, BIMI logo, plugin avatar, favicon; the sender warning shield still wins, and logos follow the sender icons setting and the no-third-party-traffic mode - the two-label public suffix list moved to `lib/root-domain.ts` SCT signatures are not verified and only the default selector is used.
370 lines
14 KiB
TypeScript
370 lines
14 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback } from "react";
|
|
import { emailDisplayDate } from "@/lib/email-date";
|
|
import { useTranslations } from "next-intl";
|
|
import { cleanPreview, formatDate } from "@/lib/utils";
|
|
import { Email } from "@/lib/jmap/types";
|
|
import { cn } from "@/lib/utils";
|
|
import { Avatar } from "@/components/ui/avatar";
|
|
import { useSenderWarning } from "@/hooks/use-sender-warning";
|
|
import { brandIndicatorDomain } from "@/lib/email-headers";
|
|
import { Paperclip, Star, Circle, CheckSquare, Square, Reply, Forward, CalendarClock } from "@/components/icons";
|
|
import { useEmailDrag } from "@/hooks/use-email-drag";
|
|
import { useLongPress } from "@/hooks/use-long-press";
|
|
import { useEmailStore } from "@/stores/email-store";
|
|
import { useShallow } from "zustand/react/shallow";
|
|
import { useSettingsStore } from "@/stores/settings-store";
|
|
import { useUIStore } from "@/stores/ui-store";
|
|
import { emailKeyFor, getEmailTagIds } from "@/lib/thread-utils";
|
|
import { useKeywordFormat } from "@/hooks/use-keyword-format";
|
|
import { useEmailTagScope } from "@/hooks/use-account-tags";
|
|
import { useTagDisplay } from "@/hooks/use-tag-display";
|
|
import { TagBadge } from "./tag-badge";
|
|
import { useListRowShape, LIST_COLUMN_CLASS } from "@/hooks/use-list-row-shape";
|
|
import { SizeCell, listLeadClass } from "./list-columns";
|
|
import { rowA11yProps, rowLabel } from "./list-a11y";
|
|
|
|
interface ThreadEmailItemProps {
|
|
email: Email;
|
|
selected?: boolean;
|
|
isLast?: boolean;
|
|
onClick?: () => void;
|
|
onDoubleClick?: () => void;
|
|
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
|
|
}
|
|
|
|
export function ThreadEmailItem({
|
|
email,
|
|
selected,
|
|
isLast = false,
|
|
onClick,
|
|
onDoubleClick,
|
|
onContextMenu,
|
|
}: ThreadEmailItemProps) {
|
|
const t = useTranslations('email_viewer');
|
|
const tBatch = useTranslations('email_list.batch_actions');
|
|
const tStatus = useTranslations('email_list');
|
|
const isUnread = !email.keywords?.$seen;
|
|
const isStarred = email.keywords?.$flagged;
|
|
const isAnswered = email.keywords?.$answered;
|
|
const isForwarded = email.keywords?.$forwarded;
|
|
const tagScope = useEmailTagScope(email);
|
|
const { sortTagIds } = useKeywordFormat(tagScope);
|
|
const { variant: tagVariant } = useTagDisplay();
|
|
// A message inside an expanded thread carries its own tags; the collapsed
|
|
// header pools them, so without this they disappear on the way in.
|
|
const tagIds = sortTagIds(getEmailTagIds(email.keywords));
|
|
const sender = email.from?.[0];
|
|
const senderWarning = useSenderWarning(email);
|
|
const { selectedMailbox, selectedEmailKeys, toggleEmailSelection, selectRangeEmails } = useEmailStore(useShallow((state) => ({
|
|
selectedMailbox: state.selectedMailbox,
|
|
selectedEmailKeys: state.selectedEmailKeys,
|
|
toggleEmailSelection: state.toggleEmailSelection,
|
|
selectRangeEmails: state.selectRangeEmails,
|
|
})));
|
|
const density = useSettingsStore((state) => state.density);
|
|
const isChecked = selectedEmailKeys.has(emailKeyFor(email));
|
|
|
|
const { dragHandlers, isDragging } = useEmailDrag({
|
|
email,
|
|
sourceMailboxId: selectedMailbox,
|
|
});
|
|
|
|
const isMobile = useUIStore((state) => state.isMobile);
|
|
const { columns } = useListRowShape();
|
|
|
|
const { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel, isPressed } = useLongPress(
|
|
useCallback((pos) => {
|
|
onContextMenu?.(
|
|
{ preventDefault: () => {}, stopPropagation: () => {}, clientX: pos.clientX, clientY: pos.clientY } as React.MouseEvent,
|
|
email
|
|
);
|
|
}, [onContextMenu, email]),
|
|
isMobile
|
|
);
|
|
const longPressHandlers = { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel };
|
|
|
|
const handleContextMenu = (e: React.MouseEvent) => {
|
|
onContextMenu?.(e, email);
|
|
};
|
|
|
|
const handleCheckboxClick = (e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
toggleEmailSelection(email);
|
|
};
|
|
|
|
const handleClick = (e: React.MouseEvent) => {
|
|
if (e.ctrlKey || e.metaKey) {
|
|
e.preventDefault();
|
|
toggleEmailSelection(email);
|
|
} else if (e.shiftKey) {
|
|
e.preventDefault();
|
|
selectRangeEmails(email);
|
|
} else if (selectedEmailKeys.size > 0) {
|
|
// Selection mode: the whole row is the checkbox (#1014).
|
|
toggleEmailSelection(email);
|
|
} else {
|
|
onClick?.();
|
|
}
|
|
};
|
|
|
|
const senderName = sender?.name || sender?.email?.split('@')[0] || t('unknown_sender');
|
|
const selectLabel = tBatch('select_row', { sender: senderName, subject: email.subject || t('no_subject') });
|
|
const a11y = rowA11yProps({
|
|
rowKey: emailKeyFor(email),
|
|
label: rowLabel([
|
|
isUnread && t('unread'),
|
|
senderName,
|
|
formatDate(emailDisplayDate(email)),
|
|
isStarred && tStatus('starred'),
|
|
isAnswered && tStatus('replied'),
|
|
isForwarded && tStatus('forwarded'),
|
|
email.hasAttachment && tStatus('has_attachment'),
|
|
// The row shows the message's text where the subject would be.
|
|
cleanPreview(email.preview),
|
|
]),
|
|
selected: !!selected,
|
|
level: 2,
|
|
checked: isChecked,
|
|
selectionMode: selectedEmailKeys.size > 0,
|
|
onToggleCheck: () => toggleEmailSelection(email),
|
|
onOpenMenu: onContextMenu ? handleContextMenu : undefined,
|
|
});
|
|
|
|
const rowHandlers = {
|
|
onClick: handleClick,
|
|
onDoubleClick: (e: React.MouseEvent) => {
|
|
if (e.ctrlKey || e.metaKey || e.shiftKey) return;
|
|
if (!onDoubleClick || selectedEmailKeys.size > 0) return;
|
|
e.preventDefault();
|
|
onDoubleClick();
|
|
},
|
|
onContextMenu: handleContextMenu,
|
|
};
|
|
|
|
if (columns) {
|
|
// A message of an unfolded conversation as a row of the table: its own
|
|
// sender and date in the columns, indented under the conversation's
|
|
// names, and its text where the subject goes (#262).
|
|
const selecting = selectedEmailKeys.size > 0;
|
|
const lead = listLeadClass(density, selecting);
|
|
return (
|
|
<div
|
|
{...dragHandlers}
|
|
{...longPressHandlers}
|
|
{...rowHandlers}
|
|
{...a11y}
|
|
data-testid="thread-email-row"
|
|
className={cn(
|
|
"relative cursor-pointer select-none px-3 transition-colors duration-150",
|
|
selected ? "bg-selection" : "hover:bg-muted/50",
|
|
isUnread && !selected && "bg-warning/10",
|
|
!isLast && "border-b border-border/30",
|
|
isChecked && "ring-2 ring-primary/20 bg-accent/40",
|
|
isDragging && "opacity-50",
|
|
isPressed && "bg-muted"
|
|
)}
|
|
style={{ paddingBlock: 'var(--density-item-py)' }}
|
|
>
|
|
<div role="gridcell" className="flex items-center" style={{ gap: 'var(--density-item-gap)' }}>
|
|
{lead && (
|
|
<span className={cn(lead, 'flex shrink-0 items-center justify-center')}>
|
|
{selecting ? (
|
|
<button
|
|
onClick={handleCheckboxClick}
|
|
role="checkbox"
|
|
aria-checked={isChecked}
|
|
aria-label={selectLabel}
|
|
className={cn("rounded p-1 hover:bg-muted/50", isChecked && "text-primary")}
|
|
>
|
|
{isChecked ? <CheckSquare className="w-3.5 h-3.5" /> : <Square className="w-3.5 h-3.5 text-muted-foreground" />}
|
|
</button>
|
|
) : isUnread ? (
|
|
<Circle className="w-1.5 h-1.5 fill-unread text-unread" />
|
|
) : null}
|
|
</span>
|
|
)}
|
|
<div className="flex min-w-0 flex-1 items-center justify-between gap-3">
|
|
<div className="flex min-w-0 flex-1 items-center gap-3">
|
|
<span className={cn(LIST_COLUMN_CLASS.from, 'flex items-center gap-2 ps-9 text-sm')}>
|
|
<span className={cn(
|
|
"min-w-0 truncate",
|
|
isUnread ? "font-semibold text-foreground" : "font-medium text-muted-foreground"
|
|
)}>
|
|
{sender?.name || sender?.email?.split('@')[0] || t('unknown_sender')}
|
|
</span>
|
|
</span>
|
|
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm">
|
|
{tagIds.map((id) => (
|
|
<TagBadge key={id} tagId={id} variant={tagVariant} scope={tagScope} />
|
|
))}
|
|
<span className={cn("min-w-0 truncate", isUnread ? "text-muted-foreground" : "text-muted-foreground")}>
|
|
{cleanPreview(email.preview) || t('no_preview_available')}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div className="flex shrink-0 items-center gap-2.5">
|
|
{email.isScheduled && <CalendarClock className="w-3.5 h-3.5 text-primary" />}
|
|
{isStarred && <Star className="w-3 h-3 fill-amber-400 text-amber-400" />}
|
|
{isAnswered && <Reply className="w-3 h-3 text-muted-foreground" />}
|
|
{isForwarded && <Forward className="w-3 h-3 text-muted-foreground" />}
|
|
{email.hasAttachment && <Paperclip className="w-3 h-3 text-muted-foreground" />}
|
|
<span className={cn(
|
|
LIST_COLUMN_CLASS.date,
|
|
"text-xs tabular-nums",
|
|
isUnread ? "text-foreground font-medium" : "text-muted-foreground"
|
|
)}>
|
|
{formatDate(emailDisplayDate(email))}
|
|
</span>
|
|
<SizeCell bytes={email.size} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
{...dragHandlers}
|
|
{...longPressHandlers}
|
|
{...a11y}
|
|
className={cn(
|
|
"relative cursor-pointer select-none transition-all duration-150",
|
|
"ps-12 pe-4",
|
|
"border-s-2 border-l-transparent",
|
|
selected
|
|
? "bg-selection border-l-primary"
|
|
: "hover:bg-muted/50",
|
|
isUnread && !selected && "bg-warning/10",
|
|
!isLast && "border-b border-border/30",
|
|
isChecked && "ring-2 ring-primary/20 bg-accent/40",
|
|
isDragging && "opacity-50 scale-[0.98] ring-2 ring-primary/30",
|
|
isPressed && "bg-muted scale-[0.98] ring-2 ring-primary/30"
|
|
)}
|
|
onClick={handleClick}
|
|
onDoubleClick={(e) => {
|
|
if (e.ctrlKey || e.metaKey || e.shiftKey) return;
|
|
if (!onDoubleClick || selectedEmailKeys.size > 0) return;
|
|
e.preventDefault();
|
|
onDoubleClick();
|
|
}}
|
|
onContextMenu={handleContextMenu}
|
|
style={{ paddingBlock: 'var(--density-item-py)' }}
|
|
>
|
|
<div role="gridcell" className="flex items-start gap-3">
|
|
{/* Checkbox - only visible when in selection mode */}
|
|
{selectedEmailKeys.size > 0 && (
|
|
<button
|
|
onClick={handleCheckboxClick}
|
|
role="checkbox"
|
|
aria-checked={isChecked}
|
|
aria-label={selectLabel}
|
|
className={cn(
|
|
"p-1 rounded mt-0.5 flex-shrink-0 transition-all duration-200",
|
|
"hover:bg-muted/50 hover:scale-110",
|
|
"active:scale-95",
|
|
"animate-in fade-in zoom-in-95 duration-150",
|
|
isChecked && "text-primary"
|
|
)}
|
|
>
|
|
{isChecked ? (
|
|
<CheckSquare className="w-3.5 h-3.5 animate-in zoom-in-50 duration-200" />
|
|
) : (
|
|
<Square className="w-3.5 h-3.5 text-muted-foreground opacity-60 hover:opacity-100 transition-opacity" />
|
|
)}
|
|
</button>
|
|
)}
|
|
|
|
{/* Unread indicator — anchored on the first line (top padding + half a
|
|
small avatar), not on the row's midpoint, so it stays in line with
|
|
the checkbox and avatar when the row wraps to a second line. */}
|
|
{isUnread && (
|
|
<div
|
|
className="absolute start-7 -translate-y-1/2"
|
|
style={{ top: `calc(var(--density-item-py) + ${density === 'extra-compact' ? '0.625rem' : '1rem'})` }}
|
|
>
|
|
<Circle className="w-1.5 h-1.5 fill-unread text-unread" />
|
|
</div>
|
|
)}
|
|
|
|
{/* Small Avatar */}
|
|
{density !== 'extra-compact' && (
|
|
<Avatar
|
|
name={sender?.name}
|
|
email={sender?.email}
|
|
size="sm"
|
|
className="flex-shrink-0"
|
|
senderWarning={senderWarning}
|
|
brandDomain={brandIndicatorDomain(email)}
|
|
/>
|
|
)}
|
|
|
|
{/* Content */}
|
|
<div className="flex-1 min-w-0">
|
|
{/* Single line: Sender, indicators, preview, date */}
|
|
<div className="flex items-center gap-2">
|
|
<span className={cn(
|
|
"truncate text-sm flex-shrink-0 max-w-[150px]",
|
|
isUnread
|
|
? "font-semibold text-foreground"
|
|
: "font-medium text-muted-foreground"
|
|
)}>
|
|
{sender?.name || sender?.email?.split('@')[0] || t('unknown_sender')}
|
|
</span>
|
|
|
|
{/* Indicators */}
|
|
<div className="flex items-center gap-1 flex-shrink-0">
|
|
{email.isScheduled && (
|
|
<CalendarClock className="w-3 h-3 text-primary" />
|
|
)}
|
|
{isStarred && (
|
|
<Star className="w-3 h-3 fill-amber-400 text-amber-400" />
|
|
)}
|
|
{isAnswered && !isForwarded && (
|
|
<Reply className="w-3 h-3 text-muted-foreground" />
|
|
)}
|
|
{isForwarded && !isAnswered && (
|
|
<Forward className="w-3 h-3 text-muted-foreground" />
|
|
)}
|
|
{isAnswered && isForwarded && (
|
|
<>
|
|
<Reply className="w-3 h-3 text-muted-foreground" />
|
|
<Forward className="w-3 h-3 text-muted-foreground" />
|
|
</>
|
|
)}
|
|
{email.hasAttachment && (
|
|
<Paperclip className="w-3 h-3 text-muted-foreground" />
|
|
)}
|
|
{tagIds.map((id) => (
|
|
<TagBadge key={id} tagId={id} variant={tagVariant} scope={tagScope} />
|
|
))}
|
|
</div>
|
|
|
|
{/* Preview snippet */}
|
|
<span className={cn(
|
|
"text-sm truncate flex-1 min-w-0",
|
|
isUnread
|
|
? "text-muted-foreground"
|
|
: "text-muted-foreground"
|
|
)}>
|
|
{cleanPreview(email.preview) || t('no_preview_available')}
|
|
</span>
|
|
|
|
{/* Date */}
|
|
<span className={cn(
|
|
"text-xs flex-shrink-0 tabular-nums",
|
|
isUnread
|
|
? "text-foreground font-medium"
|
|
: "text-muted-foreground"
|
|
)}>
|
|
{formatDate(emailDisplayDate(email))}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|