- Overview
- Anatomy
- Header
- Navigation
- Renderers
The Email Viewer is a recursive MIME viewer in the File Viewer family.
It treats email as a tree of MIME parts, renders selected leaf parts through
FileViewer, rewrites cid: URLs from multipart/related resources, and shows
body candidates and attachments in one labeled sidebar.
Northstar Foods contract packet
"use client";
import * as React from "react";
import {
EmailViewer,
type EmailViewerMessage,
} from "@/components/ui/email-viewer";
const INLINE_LOGO_CONTENT_ID = "retab-logo@fake-email.local";Installation
pnpm dlx shadcn@latest add @retab/email-viewer
Viewer composition
EmailViewer is the easy API. It expands to the same public parts you can
compose directly:
<EmailViewerProvider message={message}>
<ViewerRoot defaultOpen mode="inline" sidebarSide="right">
<EmailViewerHeader />
<ViewerBody className="flex-col md:flex-row">
<ViewerSurface>
<EmailViewerContent />
</ViewerSurface>
<ViewerSidebar aria-label="Email parts">
<EmailViewerPartsSidebar />
</ViewerSidebar>
</ViewerBody>
</ViewerRoot>
</EmailViewerProvider>The visible sidebar is a product projection: body candidates first, then
attachments. The raw MIME recursion, message scope, header model, sidebar
model, and content model are derived by the provider before the parts render.
EmailViewerHeader includes a ViewerSidebarTrigger by default; pass
trailing to place a different header action or null when the trigger lives
elsewhere.
Nested ViewerRoot
Nested ViewerRoot is correct only for a complete nested viewer. A recursive
message/rfc822 part is a complete email viewer, so it should render the
domain viewer again:
<EmailViewer message={nestedMessage} className="h-full" />A ViewerSidebarTrigger always targets the nearest ViewerRoot, so the nested
message owns its own trigger, sidebar state, and sidebar label.
Attachments are file leaves, not nested compound viewers. Render them through
FileViewerPreview inside the current surface:
<FileViewerPreview source={attachment.source} className="h-full" />Do not nest ViewerRoot just to add another controls row or border:
// Avoid: this creates another spatial viewer without another domain viewer.
<ViewerSurface>
<ViewerRoot>
<FileViewerPreview source={attachment.source} />
</ViewerRoot>
</ViewerSurface>Usage
EmailViewer is the easy API. It is the preassembled version of the composition
above.
import { EmailViewer } from "@/components/ui/email-viewer";
export function Example() {
return (
<EmailViewer
message={{
subject: "Contract packet ready for review",
from: "Mina Patel <mina@example.com>",
to: "Avery Lee <avery@example.com>",
root: {
id: "root",
mimeType: "multipart/mixed",
children: [
{
id: "related",
mimeType: "multipart/related",
children: [
{
id: "html",
mimeType: "text/html",
source: {
kind: "text",
text: '<main><img src="cid:<logo@example.com>" alt="Logo" /><p>See attached.</p></main>',
fileName: "message.html",
mimeType: "text/html",
identityKey: "message:html",
},
},
{
id: "logo",
mimeType: "image/png",
contentId: "<logo@example.com>",
disposition: "inline",
fileName: "logo.png",
source: {
kind: "url",
url: "/brand/logo.png",
fileName: "logo.png",
mimeType: "image/png",
},
},
],
},
{
id: "contract",
mimeType: "application/pdf",
disposition: "attachment",
fileName: "contract.pdf",
source: {
kind: "url",
url: "/files/contract.pdf",
fileName: "contract.pdf",
mimeType: "application/pdf",
},
},
],
},
}}
/>
);
}Behavior
- Recursive MIME tree —
multipart/*parts group children, andmessage/rfc822parts render as bounded nested email viewers. - Preferred body projection —
multipart/alternativeprefers HTML, then plain text, then the first previewable child. - CID inline resources —
multipart/relatedsiblings withcontentIdreplace matchingcid:URLs before HTML routes through the sandboxed HTML file viewer. - Parts sidebar — the sidebar groups body candidates and attachments rather than exposing the raw MIME tree as the primary UI.
- Single viewer header — the message header is the viewer header; selected leaf parts render directly in the surface without a duplicated attachment header.
- Shared preview stack — renderable leaves use
FileViewer; thumbnails useFileThumbnail.
Controlled Selection
selectedPath={undefined}leaves selection uncontrolled.selectedPath={null}clears external selection and renders the default body.- Invalid controlled paths fall back to the default body and do not emit callbacks.
onSelectedPathChangereceives normalized MIME paths, including derived suffixes for duplicate sibling ids.
API Reference
| Prop | Type | Description |
|---|---|---|
message | EmailViewerMessage | Message metadata and recursive MIME root. |
selectedPath | MimePartPath | Controlled selected MIME part path. |
defaultSelectedPath | MimePartPath | Initial selected MIME part path. |
onSelectedPathChange | function | Called with the selected path and MIME node. |
maxNestedMessageDepth | number | Maximum nested message/* preview depth. |
mode | "auto" | "inline" | "overlay" | Sidebar layout mode. |
className | string | Optional class on the email viewer root element. |
bare | boolean | Removes the outer border/background style. |
Source
"use client";
import * as React from "react";
import { FileText, Layers3, Mail, Paperclip } from "lucide-react";
import { cn } from "@/lib/utils";
import { useEmailInlineResourceUrls } from "./email-viewer-inline-resources";
import {
buildMimeTree,
deriveEmailInlineResourceScope,
deriveEmailViewerModel,