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
7.3 KiB
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:
props.previewWidth— Set when the user resizes the image in the editoreditorDomElementDOM query — ReadsnaturalWidthfrom the rendered<img>
Alignment
Image alignment is achieved through CSS margins (the <Img> component already
sets display: block):
center→margin-left: auto; margin-right: autoright→margin-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:
- Signature injection — Appends rendered signature HTML (may contain base64 images)
- Reply/forward embedding — Wraps original message content for reply/forward threads
- Base64 image extraction —
extract_base64_images_from_html()findsdata:image/…;base64,…URLs (from signatures/templates), replaces them withcid:references, and returns the extracted images as MIME attachments - 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
- 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