Files
messages/docs/email-exporter.md
Sylvain Zimmer f18277b742 🔨(devx) update developer experience: uv, rustfs, caddy, new makefile (#556)
This large PR modernizes the backend of the app:
 - Python Dependency Management: Poetry → uv
 - Object Storage for local dev: MinIO (now unmaintained) → RustFS
 - Makefile Target Standardization to align with other LaSuite repos
 - Internationalization Removal on backend: we only care about i18n on the frontend
 - Backend dependencies upgrade
2026-02-23 12:30:36 +01:00

7.3 KiB
Raw Permalink Blame History

BlockNote to Email-Safe HTML Exporter

Overview

Messages uses BlockNote as its rich-text editor. BlockNote's built-in blocksToHTMLLossy produces HTML that relies on CSS classes and modern layout which email clients strip or ignore.

The EmailExporter converts BlockNote's block tree into HTML that is safe for email rendering: every style is inline, images reference cid: URLs for MIME embedding, and layout is achieved through <table role="presentation"> wrappers generated by @react-email/components.

src/frontend/src/features/blocknote/email-exporter/
├── index.tsx          # Exporter implementation
└── index.test.tsx     # Test suite

Architecture

Conversion pipeline

BlockNote blocks (JSON)
    │
    ▼
transformBlocks()          ← Groups consecutive list items, recurses children
    │
    ▼
React nodes                ← Using @react-email/components (Section, Text, Img…)
    │
    ▼
renderToStaticMarkup()     ← react-dom/server, no hydration markers
    │
    ▼
Email-safe HTML string

Public API

import { EmailExporter } from '@/features/blocknote/email-exporter';

const exporter = new EmailExporter();

const html = exporter.exportBlocks(
  blocks,              // BlockNote document blocks
  editorDomElement,    // Editor DOM element (for image width fallback), nullable
);

Integration points

Caller File Notes
Message send message-composer/index.tsx via exportContent() ref Called at send time, not on every keystroke
Signature editor hooks/use-base64-composer.tsx Object URLs resolved to base64 after export
Template editor hooks/use-base64-composer.tsx Same hook, shared with signatures

Export is intentionally deferred to send/save time to avoid the cost of blocksToMarkdownLossy() (which creates real DOM elements) on every keystroke.

Supported blocks

Content blocks

BlockNote type HTML output Styling
paragraph <p> (via <Text>) textAlignment, textColor, backgroundColor
heading <h1><h6> (via <Heading>) level, textAlignment, textColor, backgroundColor
quote <blockquote> with left border textAlignment, textColor, backgroundColor
codeBlock <pre><code> Grey background, rounded corners
divider <hr> (via <Hr>)

List blocks

Consecutive list items of the same type are grouped into a single <ul> or <ol>, as expected for valid HTML. Nested blocks (children) are rendered recursively.

BlockNote type HTML output Notes
bulletListItem <ul><li>
numberedListItem <ol><li>
checkListItem <ul><li> with <input type="checkbox" disabled> checked prop honoured

Media blocks

BlockNote type HTML output Notes
image <Img> (optionally in <figure> with <figcaption>) See Image handling

Skipped blocks

BlockNote type Output Reason
signature Empty <span> Rendered by the backend at MIME composition time
quoted-message Empty <span> Replaced by the original message content by the backend
table Not rendered Not supported in email export
Unknown types <div> with inline content, or null Graceful fallback

Inline content

Three types of inline content are supported inside blocks:

Type Rendering
Styled text <span style="…"> (or plain text when unstyled)
Link <a> (via <Link>) with hardcoded blue color (#0b6e99)
Template variable <span data-inline-content-type="template-variable">{value}</span>

Text styles

All styles are applied as inline CSS properties:

Style CSS
bold font-weight: bold
italic font-style: italic
underline text-decoration-line: underline
strike text-decoration-line: line-through
code font-family: monospace; background-color: #f0f0f0; padding: 2px 4px
textColor color: <hex> — Named colors mapped from BlockNote palette
backgroundColor background-color: <hex> — Named colors mapped from BlockNote palette

When multiple text-decoration styles are present (e.g. underline + strikethrough), they are merged into a single text-decoration-line value ("underline line-through").

Color palette

The exporter embeds a copy of BlockNote's default color palette (not exposed via public API) to resolve named colors like "red", "blue", etc. to their hex values. Custom hex values are passed through as-is. The special value "default" produces no style.

Image handling

Images go through several transformations from the editor to the recipient's email client:

Editor (Object URL: blob:…)
    │
    ▼
EmailExporter.exportBlocks()
    │  MailHelper.replaceBlobUrlsWithCid(url)
    │  /api/v1/…/blobs/{blobId}/ → cid:{blobId}
    ▼
HTML with cid: references
    │
    ▼
Backend: prepare_outbound_message()
    │  extract_base64_images_from_html() for signature/template images
    │  Blob attachments added as MIME inline parts with matching Content-ID
    ▼
RFC 5322 MIME message
    │
    ▼
Email client renders <img src="cid:…"> from MIME parts

Width resolution

Image width is resolved with a two-step fallback:

  1. props.previewWidth — Set when the user resizes the image in the editor
  2. editorDomElement DOM query — Reads naturalWidth from the rendered <img>

Alignment

Image alignment is achieved through CSS margins (the <Img> component already sets display: block):

  • centermargin-left: auto; margin-right: auto
  • rightmargin-left: auto

Captions

When a caption is present, the image is wrapped in a <figure> with a <figcaption> element.

Backend MIME composition

The backend (core/mda/outbound.py) receives htmlBody and textBody from the frontend and builds the final RFC 5322 message:

  1. Signature injection — Appends rendered signature HTML (may contain base64 images)
  2. Reply/forward embedding — Wraps original message content for reply/forward threads
  3. Base64 image extractionextract_base64_images_from_html() finds data:image/…;base64,… URLs (from signatures/templates), replaces them with cid: references, and returns the extracted images as MIME attachments
  4. Deduplication — Images with the same SHA-256 hash share a single CID, so the same image in HTML and text bodies is attached only once
  5. MIME assembly — All blob attachments, extracted base64 images, and Drive attachment links are composed into the final multipart message

Testing

The exporter has a comprehensive test suite in src/features/blocknote/email-exporter/ covering:

  • All block types (paragraph, heading, lists, images, code, quotes, dividers)
  • Inline styles (bold, italic, underline, strike, code, colors)
  • List grouping (consecutive items, mixed types, nested lists)
  • Image handling (captions, alignment, width resolution, cid replacement)
  • Template variables
  • Edge cases (empty content, unknown blocks, combined styles)

Run with:

make test-front -- src/features/blocknote/email-exporter/index.test.tsx