Skip to content
4 changes: 2 additions & 2 deletions FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion components/email/__tests__/tag-badge.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,6 @@ describe('TagBadge', () => {
it('leaves the dot alone, having nowhere to put the control', () => {
render(<TagBadge tagId="work" variant="dot" onRemove={() => {}} />);
expect(screen.queryByRole('button')).not.toBeInTheDocument();
expect(screen.getByLabelText('Work')).toBeInTheDocument();
expect(screen.getByText('Work')).toHaveClass('sr-only');
});
});
29 changes: 21 additions & 8 deletions components/email/email-viewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -1583,8 +1584,14 @@ export function EmailViewer({

const effectiveAttachments = useMemo<EffectiveAttachment[]>(() => {
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,
Expand All @@ -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))
Expand All @@ -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')
Expand Down Expand Up @@ -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.
Expand Down
5 changes: 3 additions & 2 deletions components/email/tag-badge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,9 @@ export function TagBadge({
<span
className={cn("h-2.5 w-2.5 shrink-0 rounded-full", color.dot, className)}
title={name}
aria-label={name}
/>
>
<span className="sr-only">{name}</span>
</span>
);
}

Expand Down
20 changes: 13 additions & 7 deletions components/email/thread-conversation-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 <meta> is defense-in-depth in case the sanitizer ever
Expand Down Expand Up @@ -621,11 +632,7 @@ function EmailCard({
</div>

{/* Attachments */}
{(() => {
const visibleAttachments = (email.attachments ?? []).filter(
att => !(hideInlineImageAttachments && att.cid && att.disposition === 'inline' && (att.type || '').startsWith('image/'))
);
return visibleAttachments.length > 0 && (
{visibleAttachments.length > 0 && (
<div className="px-4 pb-4">
<div className="flex flex-wrap gap-2">
{visibleAttachments.map((attachment, idx) => {
Expand Down Expand Up @@ -657,8 +664,7 @@ function EmailCard({
})}
</div>
</div>
);
})()}
)}

{/* Action Buttons */}
<div className="px-4 pb-4 flex gap-2">
Expand Down
Loading
Loading