GitHub

Email

Render recursive MIME messages with headers, CID inline resources, nested messages, and file leaves powered by File Viewer.

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

From Mina Patel <mina@retab.example>To Avery Lee <avery@retab.example>, Ops Review <ops@retab.example>Jun 13, 2026, 1:42 PM

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 treemultipart/* parts group children, and message/rfc822 parts render as bounded nested email viewers.
  • Preferred body projectionmultipart/alternative prefers HTML, then plain text, then the first previewable child.
  • CID inline resourcesmultipart/related siblings with contentId replace matching cid: 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 use FileThumbnail.

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.
  • onSelectedPathChange receives normalized MIME paths, including derived suffixes for duplicate sibling ids.

API Reference

PropTypeDescription
messageEmailViewerMessageMessage metadata and recursive MIME root.
selectedPathMimePartPathControlled selected MIME part path.
defaultSelectedPathMimePartPathInitial selected MIME part path.
onSelectedPathChangefunctionCalled with the selected path and MIME node.
maxNestedMessageDepthnumberMaximum nested message/* preview depth.
mode"auto" | "inline" | "overlay"Sidebar layout mode.
classNamestringOptional class on the email viewer root element.
barebooleanRemoves the outer border/background style.

Source

email-viewer.tsx