diff --git a/FEATURES.md b/FEATURES.md index 1577a2db6..d37939c92 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -4,8 +4,8 @@ - Read, compose, reply, reply-all, and forward in a Tiptap rich-text editor that handles inline images, drag-and-drop embedding, and tables - Gmail-style threading, expanded inline, with a conversation toggle you can switch off -- The Unified Mailbox combines Inbox, Sent, Drafts, Junk, Archive, and Trash. By default it stays inside the active account and its shared/group folders; an admin can unlock a cross-account mode that spans every connected account. -- All mail, Unread, and Starred obey that same account boundary and can be narrowed to a per-account folder selection. Every row names the folder its message came from. +- The Unified Mailbox combines Inbox, Sent, Drafts, Junk, Archive, and Trash. By default it stays inside the active account and its shared/group folders; an admin can unlock a cross-account mode that spans every connected account. The combined folder rows only appear once 2+ accounts are connected (or "Include group inboxes" is on and a group inbox exists) — with just one account and no group inbox, turning the setting on adds the section header but nothing under it. +- All mail, Unread, and Starred obey that same account boundary and can be narrowed to a per-account folder selection. Every row names the folder its message came from. Each of these three is a separate admin policy gate (see [Admin & extensibility](#admin--extensibility)) — if none are enabled for your instance, the toggles for them do not even appear in Settings → Appearance, and the Unified Mailbox section can look empty even with the feature turned on and multiple accounts connected. Ask your admin to enable the ones you need. - Search runs across all unified views; the per-role mailboxes add the full filter panel on top - Three mail layouts: split three-pane, focused list, or reading pane at the bottom - Drafts auto-save, keeping the chosen identity, the HTML body, and correct `In-Reply-To` / `References` headers on replies diff --git a/components/email/__tests__/tag-badge.test.tsx b/components/email/__tests__/tag-badge.test.tsx index 70775ff6a..990e84bff 100644 --- a/components/email/__tests__/tag-badge.test.tsx +++ b/components/email/__tests__/tag-badge.test.tsx @@ -36,6 +36,6 @@ describe('TagBadge', () => { it('leaves the dot alone, having nowhere to put the control', () => { render( {}} />); expect(screen.queryByRole('button')).not.toBeInTheDocument(); - expect(screen.getByLabelText('Work')).toBeInTheDocument(); + expect(screen.getByText('Work')).toHaveClass('sr-only'); }); }); diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 0f912d5a8..cd770e915 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -6,6 +6,7 @@ import { emailExportFilename, attachmentDownloadFilename, attachmentsBundleFilen import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import"; import { applyNewTabToAnchor, escapeHtml, plainTextToSafeHtml, sanitizeEmailBodyForIframe, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization"; import { getRenderableHtmlBody } from "@/lib/email-body-selection"; +import { collectReferencedCids, isEmbeddedInBody } from "@/lib/attachment-visibility"; import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collapse"; import { fitEmailBodyWidth } from "@/lib/email-fit-width"; import { withBasePath } from "@/lib/browser-navigation"; @@ -1583,8 +1584,14 @@ export function EmailViewer({ const effectiveAttachments = useMemo(() => { if (pluginRenderedAttachments.length > 0) { + const pluginCids = collectReferencedCids(hideInlineImageAttachments ? pluginRenderedHtml : null); return pluginRenderedAttachments - .filter(att => !(hideInlineImageAttachments && att.contentId && (att.mimeType || '').startsWith('image/'))) + // Any cid image counts as embedded here (as before), plus whatever the + // decrypted body references by cid (see lib/attachment-visibility.ts). + .filter(att => !(hideInlineImageAttachments && ( + (att.contentId && (att.mimeType || '').startsWith('image/')) + || isEmbeddedInBody({ cid: att.contentId, type: att.mimeType, disposition: att.disposition }, pluginCids) + ))) .map((attachment, index) => ({ id: `smime-${index}-${attachment.filename || attachment.mimeType}`, name: attachment.filename, @@ -1596,6 +1603,12 @@ export function EmailViewer({ } const hasCalInvitation = calendarInvitationParsingEnabled && !!email && !!findCalendarAttachment(email); + // Parts the rendered body embeds via cid: must not double as chips. The + // scan reads the same HTML the body renders from (null when the message + // renders as plain text: nothing embedded, nothing hidden), so a part only + // recognisable by its reference - octet-stream, no disposition, no name - + // is caught as well (see lib/attachment-visibility.ts). + const bodyCids = collectReferencedCids(hideInlineImageAttachments && email ? getRenderableHtmlBody(email) : null); const jmapAttachments = (email?.attachments ?? []) // Hide winmail.dat when we have successfully extracted TNEF content or attachments .filter(att => !(tnefHtml || tnefText || tnefAttachments.length > 0) || !isTnefAttachment(att.name, att.type)) @@ -1605,9 +1618,9 @@ export function EmailViewer({ // Hide calendar MIME parts (text/calendar, application/ics) when the invitation // banner is shown - prevents raw ICS files appearing as spurious attachments. .filter(att => !hasCalInvitation || !isCalendarMimeType(att.type)) - // Hide inline cid-referenced images when the user has opted to keep them - // out of the attachment list (default on): these are embedded in the body. - .filter(att => !(hideInlineImageAttachments && att.cid && att.disposition === 'inline' && (att.type || '').startsWith('image/'))) + // Hide body-embedded parts when the user has opted to keep them out of + // the attachment list (default on). + .filter(att => !(hideInlineImageAttachments && isEmbeddedInBody(att, bodyCids))) // Hide machine-readable report parts (MDN read-receipts, DSN bounce // reports). These are required MIME parts, not real user attachments. .filter(att => att.type !== 'message/disposition-notification' && att.type !== 'message/delivery-status') @@ -1642,11 +1655,11 @@ export function EmailViewer({ return [...jmapAttachments, ...tnefExtracted, ...embeddedExtracted]; // The memo derives only from `email.attachments` (findCalendarAttachment - // scans that array); depending on the whole `email` object would rebuild the - // attachment list — and its downstream layout measurement — on every email - // field change. + // scans that array) and the body parts the cid scan reads; depending on + // the whole `email` object would rebuild the attachment list — and its + // downstream layout measurement — on every email field change. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [email?.attachments, pluginRenderedAttachments, tnefHtml, tnefText, tnefAttachments, embeddedEmailAttachments, calendarInvitationParsingEnabled, hideInlineImageAttachments]); + }, [email?.attachments, email?.htmlBody, email?.textBody, email?.bodyValues, pluginRenderedAttachments, pluginRenderedHtml, tnefHtml, tnefText, tnefAttachments, embeddedEmailAttachments, calendarInvitationParsingEnabled, hideInlineImageAttachments]); // Measure attachment chips in the below-header row to determine how many fit // on a single line; the rest collapse into a "+N attachments" overflow pill. diff --git a/components/email/tag-badge.tsx b/components/email/tag-badge.tsx index ca36f35f1..0cfa385c9 100644 --- a/components/email/tag-badge.tsx +++ b/components/email/tag-badge.tsx @@ -63,8 +63,9 @@ export function TagBadge({ + > + {name} + ); } diff --git a/components/email/thread-conversation-view.tsx b/components/email/thread-conversation-view.tsx index 2e720d4b0..3374d706b 100644 --- a/components/email/thread-conversation-view.tsx +++ b/components/email/thread-conversation-view.tsx @@ -5,6 +5,7 @@ import DOMPurify from "dompurify"; import { Email, ThreadGroup } from "@/lib/jmap/types"; import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers, plainTextToSafeHtml, restrictDataUriResourcesOnNode, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization"; import { getRenderableHtmlBody } from "@/lib/email-body-selection"; +import { collectReferencedCids, isEmbeddedInBody } from "@/lib/attachment-visibility"; import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collapse"; import { fitEmailBodyWidth } from "@/lib/email-fit-width"; import { transformInlineStyles, transformColorForDarkMode, transformBgColorForDarkMode } from "@/lib/color-transform"; @@ -436,6 +437,16 @@ function EmailCard({ return { html: "", isHtml: false }; }, [email, allowExternal, resolvedTheme, emailAlwaysLightMode, cidBlobUrls, t]); + // Parts the body embeds via cid: stay out of the attachment row while the + // user hides inline images - the desktop viewer's rule, shared through + // lib/attachment-visibility.ts so the two views cannot drift apart. + const visibleAttachments = useMemo(() => { + const attachments = email.attachments ?? []; + if (!hideInlineImageAttachments) return attachments; + const bodyCids = collectReferencedCids(getRenderableHtmlBody(email)); + return attachments.filter(att => !isEmbeddedInBody(att, bodyCids)); + }, [email, hideInlineImageAttachments]); + // Render the sanitized HTML body inside a sandboxed iframe so a malicious // (or accidentally-bypassed) email cannot inject styles/scripts/forms into // the host page. CSP is defense-in-depth in case the sanitizer ever @@ -621,11 +632,7 @@ function EmailCard({ {/* Attachments */} - {(() => { - const visibleAttachments = (email.attachments ?? []).filter( - att => !(hideInlineImageAttachments && att.cid && att.disposition === 'inline' && (att.type || '').startsWith('image/')) - ); - return visibleAttachments.length > 0 && ( + {visibleAttachments.length > 0 && (
{visibleAttachments.map((attachment, idx) => { @@ -657,8 +664,7 @@ function EmailCard({ })}
- ); - })()} + )} {/* Action Buttons */}
diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx index 9a64524db..189e8a60b 100644 --- a/components/email/thread-list-item.tsx +++ b/components/email/thread-list-item.tsx @@ -10,7 +10,7 @@ import type { LucideIcon } from "lucide-react"; import { useSettingsStore } from "@/stores/settings-store"; import { useUIStore } from "@/stores/ui-store"; import { useEmailStore } from "@/stores/email-store"; -import { useAccountStore } from "@/stores/account-store"; +import { useAccountStore, type AccountEntry } from "@/stores/account-store"; import { getThreadTagIds, getEmailTagIds } from "@/lib/thread-utils"; import { useKeywordFormat } from "@/hooks/use-keyword-format"; import { useTagDisplay } from "@/hooks/use-tag-display"; @@ -34,6 +34,7 @@ import { useTranslations } from "next-intl"; * a column with. Anchor = top padding + half an avatar. */ function UnreadDot({ density, compactAvatar }: { density: string; compactAvatar: boolean }) { + const t = useTranslations('email_viewer'); const halfFirstLine = density === 'extra-compact' ? '0.625rem' : compactAvatar ? '1rem' : '1.25rem'; return (
+ {t('unread')}
); } +function StatusIcon({ icon: Icon, label, className }: { icon: LucideIcon; label: string; className: string }) { + return ( + <> + + {label} + + ); +} + +function describeAccount(label: string | undefined, account: AccountEntry | undefined): string | undefined { + const address = account?.email || account?.username; + if (!label) return address; + if (!address || address.toLowerCase() === label.toLowerCase()) return label; + return `${label}, ${address}`; +} + /** * Small chip showing the originating folder of a message, rendered in the * aggregate "All …" views (All Mail / unified / cross-account) where rows come @@ -79,7 +97,8 @@ function ThreadCountPill({ count, hasUnread, title }: { count: number; hasUnread title={title} > - {count} + + {title} ); } @@ -136,6 +155,7 @@ const SingleEmailItem = React.forwardRef( function SingleEmailItem({ email, selected, onClick, onDoubleClick, onContextMenu, showPreview, rowTint, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetTag, onMarkAsSpam, onUndoSpam }, ref) { 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 isPinned = email.keywords?.['$pinned'] === true; @@ -160,7 +180,9 @@ const SingleEmailItem = React.forwardRef( // Show the originating folder in the aggregate "All …" views. const showSourceFolder = isUnifiedView && !!email.sourceFolder; const getAccountById = useAccountStore((state) => state.getAccountById); - const accountColor = email.accountId ? getAccountById(email.accountId)?.avatarColor : undefined; + const account = email.accountId ? getAccountById(email.accountId) : undefined; + const accountColor = account?.avatarColor; + const accountDescription = describeAccount(email.accountLabel, account); const isChecked = selectedEmailIds.has(email.id); const isMobile = useUIStore((state) => state.isMobile); // The horizontal one-line "focus" layout doesn't fit on narrow screens; fall back to multi-line on mobile. @@ -275,6 +297,7 @@ const SingleEmailItem = React.forwardRef( data-subject={email.subject || ''} data-unread={isUnread ? 'true' : 'false'} data-starred={email.keywords?.$flagged ? 'true' : 'false'} + aria-current={selected ? 'true' : undefined} className={cn( "relative group cursor-pointer select-none transition-shadow duration-200 border-b border-border overflow-hidden", resolvedRowTint ? resolvedRowTint : ( @@ -324,6 +347,9 @@ const SingleEmailItem = React.forwardRef( {density === 'extra-compact' && selectedEmailIds.size > 0 && ( )} - {isUnread && ( - - )} - {density !== 'extra-compact' && ( ( /> )} + {isUnread && ( + + )} +
{isFocusedMailLayout ? (
@@ -366,8 +392,10 @@ const SingleEmailItem = React.forwardRef( + title={accountDescription} + > + {accountDescription} + )} (
- {isPinned && } - {isStarred && } - {isAnswered && !isForwarded && } - {isForwarded && !isAnswered && } + {isPinned && } + {isStarred && } + {isAnswered && !isForwarded && } + {isForwarded && !isAnswered && } {isAnswered && isForwarded && ( <> - - + + )} - {email.hasAttachment && } + {email.hasAttachment && } {showSourceFolder && } {scheduledSendLabel ? ( ( + title={accountDescription} + > + {accountDescription} + )} ( )}
{isPinned && ( - + )} {isStarred && ( - + )} {isAnswered && !isForwarded && ( - + )} {isForwarded && !isAnswered && ( - + )} {isAnswered && isForwarded && ( <> - - + + )} {email.hasAttachment && ( - + )}
@@ -578,6 +608,7 @@ export const ThreadListItem = React.forwardRef state.showPreview); const density = useSettingsStore((state) => state.density); const mailLayout = useSettingsStore((state) => state.mailLayout); @@ -601,7 +632,9 @@ export const ThreadListItem = React.forwardRef state.getAccountById); - const threadAccountColor = latestEmail.accountId ? getAccountById(latestEmail.accountId)?.avatarColor : undefined; + const threadAccount = latestEmail.accountId ? getAccountById(latestEmail.accountId) : undefined; + const threadAccountColor = threadAccount?.avatarColor; + const threadAccountDescription = describeAccount(latestEmail.accountLabel, threadAccount); // In Sent/Drafts folders, show recipient instead of sender (which is always // "me"). Aggregate role-views use a virtual selected mailbox → fall back to // the unified role so junk-contextual UI and avatar hiding work. @@ -732,6 +765,7 @@ export const ThreadListItem = React.forwardRef 0 && (