✨(global) allow to add image block into template and signature composers

Images are embedded as base64 data URLs directly in the BlockNote content,
unlike the message composer which uses blob uploads + CID references.
This approach keeps templates and signatures self-contained without
requiring an attachment system.

Furthermore, a email-safe safe html exporter has been created to
serialize blocknote content into html.

A new backend setting MAX_TEMPLATE_IMAGE_SIZE (default 2 MiB) controls
the maximum allowed image size for these composers.
This commit is contained in:
jbpenrath
2026-02-17 19:02:28 +01:00
parent 100f671e1e
commit c53e8bfcd4
58 changed files with 3809 additions and 1025 deletions
+216
View File
@@ -0,0 +1,216 @@
# BlockNote to Email-Safe HTML Exporter
## Overview
Messages uses [BlockNote](https://www.blocknotejs.org/) 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](https://react.email/).
```
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
```ts
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](#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`):
- `center` → `margin-left: auto; margin-right: auto`
- `right` → `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:
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 extraction** — `extract_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:
```bash
make front-test -- src/features/blocknote/email-exporter/index.test.tsx
```
+6 -1
View File
@@ -260,7 +260,12 @@ _Those settings are deprecated and will be removed in the future._
|----------|---------|-------------|----------|
| `TRASHBIN_CUTOFF_DAYS` | `30` | Days before permanent deletion | Optional |
| `INVITATION_VALIDITY_DURATION` | `604800` | Invitation validity (7 days) | Optional |
| `MESSAGES_MANUAL_RETRY_MAX_AGE` | `604800` | Maximum age in seconds for a message to be eligible for manual retry of failed deliveries (7 days) | Optional |
| `MESSAGES_MANUAL_RETRY_MAX_AGE`| `604800` | Maximum age in seconds for a message to be eligible for manual retry of failed deliveries (7 days) | Optional |
| `MAX_INCOMING_EMAIL_SIZE` | `10485760` | Maximum size in bytes for incoming email (including attachments and body) (10MB) | Optional |
| `MAX_OUTGOING_ATTACHMENT_SIZE` | `20971520` | Maximum size in bytes for outgoing email attachments (20MB) | Optional |
| `MAX_OUTGOING_BODY_SIZE` | `5242880` | Maximum size in bytes for outgoing email body (text + HTML) (5MB) | Optional |
| `MAX_TEMPLATE_IMAGE_SIZE` | `2097152` | Maximum size in bytes for images embedded in templates and signatures (2MB) | Optional |
| `MAX_RECIPIENTS_PER_MESSAGE` | `500` | Maximum number of recipients per message (to + cc + bcc) | Optional |
### Model custom attributes schema
+68 -74
View File
@@ -235,6 +235,11 @@
"description": "Maximum number of recipients per message (to + cc + bcc)",
"readOnly": true
},
"MAX_TEMPLATE_IMAGE_SIZE": {
"type": "integer",
"description": "Maximum size in bytes for images embedded in templates and signatures",
"readOnly": true
},
"IMAGE_PROXY_ENABLED": {
"type": "boolean",
"description": "Whether external images should be proxied",
@@ -260,6 +265,7 @@
"MAX_OUTGOING_BODY_SIZE",
"MAX_INCOMING_EMAIL_SIZE",
"MAX_RECIPIENTS_PER_MESSAGE",
"MAX_TEMPLATE_IMAGE_SIZE",
"IMAGE_PROXY_ENABLED",
"MESSAGES_MANUAL_RETRY_MAX_AGE"
]
@@ -1309,6 +1315,63 @@
}
}
},
"/api/v1.0/draft/{message_id}/placeholders/": {
"get": {
"operationId": "draft_placeholders_retrieve",
"description": "Resolve placeholder values for the authenticated user in the context of a draft message. The mailbox is derived from the draft's sender. recipient_name is resolved from the draft's TO recipients.",
"summary": "Resolve placeholder values for a draft",
"parameters": [
{
"in": "path",
"name": "message_id",
"schema": {
"type": "string",
"format": "uuid"
},
"required": true
}
],
"tags": [
"messages"
],
"security": [
{
"cookieAuth": []
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"type": "object",
"description": "Placeholder keys mapped to their resolved values",
"additionalProperties": {
"type": "string"
},
"example": {
"name": "John Doe",
"recipient_name": "Jane Smith",
"job_title": "Developer"
}
}
}
},
"description": ""
},
"404": {
"content": {
"application/json": {
"schema": {
"description": "Draft not found"
}
}
},
"description": ""
}
}
}
},
"/api/v1.0/flag/": {
"post": {
"operationId": "flag_create",
@@ -2851,7 +2914,7 @@
},
"post": {
"operationId": "mailboxes_message_templates_create",
"description": "ViewSet for retrieving and rendering message templates for a mailbox.",
"description": "ViewSet for managing message templates for a mailbox.",
"parameters": [
{
"in": "path",
@@ -2903,7 +2966,7 @@
"/api/v1.0/mailboxes/{mailbox_id}/message-templates/{id}/": {
"get": {
"operationId": "mailboxes_message_templates_retrieve",
"description": "ViewSet for retrieving and rendering message templates for a mailbox.",
"description": "ViewSet for managing message templates for a mailbox.",
"parameters": [
{
"in": "path",
@@ -2946,7 +3009,7 @@
},
"put": {
"operationId": "mailboxes_message_templates_update",
"description": "ViewSet for retrieving and rendering message templates for a mailbox.",
"description": "ViewSet for managing message templates for a mailbox.",
"parameters": [
{
"in": "path",
@@ -3004,7 +3067,7 @@
},
"patch": {
"operationId": "mailboxes_message_templates_partial_update",
"description": "ViewSet for retrieving and rendering message templates for a mailbox.",
"description": "ViewSet for managing message templates for a mailbox.",
"parameters": [
{
"in": "path",
@@ -3061,7 +3124,7 @@
},
"delete": {
"operationId": "mailboxes_message_templates_destroy",
"description": "ViewSet for retrieving and rendering message templates for a mailbox.",
"description": "ViewSet for managing message templates for a mailbox.",
"parameters": [
{
"in": "path",
@@ -3096,75 +3159,6 @@
}
}
},
"/api/v1.0/mailboxes/{mailbox_id}/message-templates/{id}/render/": {
"get": {
"operationId": "mailboxes_message_templates_render_retrieve",
"description": "Render a template with the provided context variables.",
"parameters": [
{
"in": "query",
"name": "*",
"schema": {
"type": "string"
},
"description": "Any other parameter will be available in the template context",
"examples": {
"Example": {
"value": "value"
}
}
},
{
"in": "path",
"name": "id",
"schema": {
"type": "string"
},
"required": true
},
{
"in": "path",
"name": "mailbox_id",
"schema": {
"type": "string",
"format": "uuid"
},
"required": true
}
],
"tags": [
"mailboxes"
],
"security": [
{
"cookieAuth": []
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"html_body": {
"type": "string"
},
"text_body": {
"type": "string"
}
}
}
}
},
"description": "Template rendered with provided context"
},
"404": {
"description": "Template not found"
}
}
}
},
"/api/v1.0/mailboxes/{mailbox_id}/message-templates/available/": {
"get": {
"operationId": "mailboxes_message_templates_available_list",
+38
View File
@@ -15,6 +15,7 @@ from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied
from core import enums, models
from core.mda.rfc5322 import extract_base64_images_from_html
class IntegerChoicesField(serializers.ChoiceField):
@@ -1502,6 +1503,43 @@ class MessageTemplateSerializer(serializers.ModelSerializer):
"All content fields (html_body, text_body, raw_body) must be provided together."
)
if "html_body" in attrs:
_html, images = extract_base64_images_from_html(attrs["html_body"])
total_image_size = 0
for image in images:
total_image_size += image["size"]
if image["size"] > settings.MAX_TEMPLATE_IMAGE_SIZE:
max_mb = settings.MAX_TEMPLATE_IMAGE_SIZE / (1024 * 1024)
image_mb = image["size"] / (1024 * 1024)
raise serializers.ValidationError(
{
"html_body": _(
'Image "%(name)s" (%(size)s MB) exceeds'
" the %(max)s MB limit."
)
% {
"name": image["name"],
"size": f"{image_mb:.1f}",
"max": f"{max_mb:.0f}",
}
}
)
if total_image_size > settings.MAX_OUTGOING_ATTACHMENT_SIZE:
max_mb = settings.MAX_OUTGOING_ATTACHMENT_SIZE / (1024 * 1024)
total_mb = total_image_size / (1024 * 1024)
raise serializers.ValidationError(
{
"html_body": _(
"Total attachment size (%(total_size)s MB) exceeds the %(max_size)s MB limit. "
"Please remove or reduce attachments."
)
% {
"total_size": f"{total_mb:.1f}",
"max_size": f"{max_mb:.0f}",
}
}
)
return super().validate(attrs)
def create(self, validated_data):
+7
View File
@@ -101,6 +101,11 @@ class ConfigView(drf.views.APIView):
),
"readOnly": True,
},
"MAX_TEMPLATE_IMAGE_SIZE": {
"type": "integer",
"description": "Maximum size in bytes for images embedded in templates and signatures",
"readOnly": True,
},
"IMAGE_PROXY_ENABLED": {
"type": "boolean",
"description": "Whether external images should be proxied",
@@ -129,6 +134,7 @@ class ConfigView(drf.views.APIView):
"MAX_OUTGOING_BODY_SIZE",
"MAX_INCOMING_EMAIL_SIZE",
"MAX_RECIPIENTS_PER_MESSAGE",
"MAX_TEMPLATE_IMAGE_SIZE",
"IMAGE_PROXY_ENABLED",
"MESSAGES_MANUAL_RETRY_MAX_AGE",
],
@@ -148,6 +154,7 @@ class ConfigView(drf.views.APIView):
"LANGUAGE_CODE",
"SCHEMA_CUSTOM_ATTRIBUTES_USER",
"SCHEMA_CUSTOM_ATTRIBUTES_MAILDOMAIN",
"MAX_TEMPLATE_IMAGE_SIZE",
"IMAGE_PROXY_ENABLED",
"MESSAGES_MANUAL_RETRY_MAX_AGE",
"FEATURE_MAILBOX_ADMIN_CHANNELS",
@@ -4,16 +4,12 @@ from django.db.models import Case, IntegerField, Q, When
from django.utils.functional import cached_property
from drf_spectacular.utils import (
OpenApiExample,
OpenApiParameter,
OpenApiResponse,
OpenApiTypes,
extend_schema,
)
from rest_framework import mixins, status, viewsets
from rest_framework.decorators import action
from rest_framework import mixins, viewsets
from rest_framework.generics import get_object_or_404
from rest_framework.response import Response
from core.api import permissions
from core.api.serializers import (
@@ -35,7 +31,7 @@ class MailboxMessageTemplateViewSet(
mixins.ListModelMixin,
viewsets.GenericViewSet,
):
"""ViewSet for retrieving and rendering message templates for a mailbox."""
"""ViewSet for managing message templates for a mailbox."""
permission_classes = [permissions.IsMailboxAdmin]
serializer_class = MessageTemplateSerializer
@@ -44,7 +40,7 @@ class MailboxMessageTemplateViewSet(
def get_permissions(self):
"""Get permissions for the viewset."""
if self.action in ["render_template", "list", "retrieve"]:
if self.action in ["list", "retrieve"]:
return [permissions.HasAccessToMailbox()]
return super().get_permissions()
@@ -55,11 +51,12 @@ class MailboxMessageTemplateViewSet(
def get_queryset(self):
"""Get message templates for a mailbox the user has access to."""
if self.action == "render_template":
return MessageTemplate.objects.filter(
if self.action == "retrieve":
queryset = MessageTemplate.objects.filter(
Q(mailbox=self.mailbox) | Q(maildomain=self.mailbox.domain)
)
queryset = MessageTemplate.objects.filter(mailbox=self.mailbox)
else:
queryset = MessageTemplate.objects.filter(mailbox=self.mailbox)
template_types = [
MessageTemplateTypeChoices[template_type.upper()]
for template_type in self.request.query_params.getlist("type")
@@ -75,49 +72,6 @@ class MailboxMessageTemplateViewSet(
context["mailbox"] = self.mailbox
return context
@extend_schema(
parameters=[
OpenApiParameter(
name="*",
type=OpenApiTypes.STR,
location=OpenApiParameter.QUERY,
description="Any other parameter will be available in the template context",
required=False,
examples=[OpenApiExample("Example", value="value")],
)
],
responses={
200: OpenApiResponse(
description="Template rendered with provided context",
response={
"type": "object",
"properties": {
"html_body": {"type": "string"},
"text_body": {"type": "string"},
},
},
),
404: OpenApiResponse(description="Template not found"),
},
description="Render a template with the provided context variables.",
)
@action(detail=True, methods=["get"], url_path="render")
def render_template(self, request, mailbox_id, pk=None): # pylint: disable=unused-argument
"""Render a template."""
template = self.get_object()
try:
rendered = template.render_template(
mailbox=self.mailbox,
user=request.user,
context=request.query_params.dict(),
)
return Response(rendered)
except (KeyError, ValueError, TypeError) as e:
return Response(
{"error": f"Failed to render template: {str(e)}"},
status=status.HTTP_400_BAD_REQUEST,
)
@extend_schema(
responses=MessageTemplateSerializer(many=True),
description="List message templates for a mailbox.",
@@ -1,13 +1,17 @@
"""Views for placeholder field structure information."""
from django.conf import settings
from django.db.models import F
from django.utils import translation
from drf_spectacular.utils import extend_schema
from rest_framework import permissions
from rest_framework.exceptions import NotFound
from rest_framework.response import Response
from rest_framework.views import APIView
from core import enums, models
@extend_schema(tags=["placeholders"])
class PlaceholderView(APIView):
@@ -64,3 +68,67 @@ class PlaceholderView(APIView):
label = field_schema.get("title", field_name)
fields[field_name] = label
return Response(fields)
@extend_schema(tags=["messages"])
class DraftPlaceholderView(APIView):
"""
Resolve placeholder values in the context of a draft message.
The authenticated user must have editor-level access to the mailbox
that owns the draft, and that mailbox must have editor access to the
draft's thread.
Returns actual values (not labels) that should be substituted into
template placeholders: sender name, custom user attributes, and
recipient_name from the draft's TO recipients.
"""
permission_classes = [permissions.IsAuthenticated]
@extend_schema(
summary="Resolve placeholder values for a draft",
description=(
"Resolve placeholder values for the authenticated user in the "
"context of a draft message. The mailbox is derived from the "
"draft's sender. recipient_name is resolved from the draft's "
"TO recipients."
),
responses={
200: {
"type": "object",
"description": "Placeholder keys mapped to their resolved values",
"additionalProperties": {"type": "string"},
"example": {
"name": "John Doe",
"recipient_name": "Jane Smith",
"job_title": "Developer",
},
},
404: {"description": "Draft not found"},
},
)
def get(self, request, message_id):
"""Resolve placeholder values for the given draft context."""
try:
message = models.Message.objects.select_related("sender__mailbox").get(
id=message_id,
is_draft=True,
# User has CAN_EDIT role on the sender's mailbox
sender__mailbox__accesses__user=request.user,
sender__mailbox__accesses__role__in=enums.MAILBOX_ROLES_CAN_EDIT,
# The sender's mailbox has EDITOR access to the thread
thread__accesses__mailbox=F("sender__mailbox"),
thread__accesses__role=enums.ThreadAccessRoleChoices.EDITOR,
)
except models.Message.DoesNotExist as exc:
raise NotFound(
"Draft message not found, is not a draft, or access denied."
) from exc
mailbox = message.sender.mailbox
context = models.MessageTemplate.resolve_placeholder_values(
mailbox=mailbox, user=request.user, message=message
)
return Response(context)
+74 -31
View File
@@ -20,6 +20,8 @@ from core.mda.rfc5322 import (
compose_email,
create_forward_message,
create_reply_message,
extract_base64_images_from_html,
extract_base64_images_from_text,
parse_email_message,
)
from core.mda.signing import sign_message_dkim, verify_message_dkim
@@ -94,7 +96,7 @@ def prepare_outbound_message(
if message.signature:
try:
signatures = message.signature.render_template(
mailbox=mailbox_sender, user=user
mailbox=mailbox_sender, user=user, message=message
)
if signatures:
text_body = (
@@ -148,6 +150,25 @@ def prepare_outbound_message(
if nested_data.get("htmlBody"):
html_body = nested_data["htmlBody"][0]["content"]
# Extract base64 images from text and HTML bodies (e.g. from signatures
# and templates) and convert them to inline CID attachments.
# A shared known_images dict deduplicates identical images across both bodies
# so the same image is attached only once.
known_images: dict[str, str] = {}
base64_inline_attachments = []
if text_body:
text_body, text_images = extract_base64_images_from_text(
text_body, known_images=known_images
)
base64_inline_attachments.extend(text_images)
if html_body:
html_body, html_images = extract_base64_images_from_html(
html_body, known_images=known_images
)
base64_inline_attachments.extend(html_images)
# Generate the MIME data dictionary
mime_data = {
"from": [
@@ -166,39 +187,20 @@ def prepare_outbound_message(
"message_id": message.mime_id,
}
# Add attachments if present
if message.attachments.exists():
attachments = []
total_attachment_size = 0
for attachment in message.attachments.select_related("blob").all():
# Get the blob data
blob = attachment.blob
total_attachment_size += blob.size
# Add the attachment to the MIME data
# Use inline disposition if attachment has a Content-ID (for inline images)
attachments.append(
{
"content": blob.get_content(), # Decompressed binary content
"type": blob.content_type, # MIME type
"name": attachment.name, # Original filename
"disposition": "inline" if attachment.cid else "attachment",
"cid": attachment.cid, # Content-ID for inline images
"size": blob.size, # Size in bytes
}
)
# Validate total attachment size before composing
if total_attachment_size > settings.MAX_OUTGOING_ATTACHMENT_SIZE:
# Add attachments if present and ensure they don't exceed the limit
def _validate_attachments_size(total_size: int) -> None:
"""
Validate that the total size of the attachments does not exceed the limit.
"""
if total_size > settings.MAX_OUTGOING_ATTACHMENT_SIZE:
# Use binary MB (MiB) to match frontend formatting
total_mb = total_attachment_size / (1024 * 1024)
total_mb = total_size / (1024 * 1024)
max_mb = settings.MAX_OUTGOING_ATTACHMENT_SIZE / (1024 * 1024)
logger.error(
"Total attachment size for message %s exceeds limit: %d bytes (%.1f MB) > %d bytes (%.0f MB)",
message.id,
total_attachment_size,
total_size,
total_mb,
settings.MAX_OUTGOING_ATTACHMENT_SIZE,
max_mb,
@@ -217,9 +219,48 @@ def prepare_outbound_message(
}
)
# Add attachments to the MIME data
if attachments:
mime_data["attachments"] = attachments
attachments = []
total_attachment_size = 0
if message.attachments.exists():
for attachment in message.attachments.select_related("blob").all():
# Get the blob data
blob = attachment.blob
total_attachment_size += blob.size
# Add the attachment to the MIME data
# Use inline disposition if attachment has a Content-ID (for inline images)
attachments.append(
{
"content": blob.get_content(), # Decompressed binary content
"type": blob.content_type, # MIME type
"name": attachment.name, # Original filename
"disposition": "inline" if attachment.cid else "attachment",
"cid": attachment.cid, # Content-ID for inline images
"size": blob.size, # Size in bytes
}
)
_validate_attachments_size(total_attachment_size)
# Add base64-extracted inline images as attachments
if base64_inline_attachments:
for img in base64_inline_attachments:
total_attachment_size += img["size"]
attachments.append(
{
"content": img["content"],
"type": img["content_type"],
"name": img["name"],
"disposition": "inline",
"cid": img["cid"],
"size": img["size"],
}
)
_validate_attachments_size(total_attachment_size)
# Add attachments to the MIME data
if attachments:
mime_data["attachments"] = attachments
# Assemble the raw mime message
try:
@@ -285,6 +326,7 @@ def prepare_outbound_message(
message.blob = blob
message.is_draft = False
message.draft_blob = None
message.has_attachments = len(attachments) > 0
message.created_at = timezone.now()
message.updated_at = timezone.now()
message.save(
@@ -294,6 +336,7 @@ def prepare_outbound_message(
"mime_id",
"is_draft",
"draft_blob",
"has_attachments",
"created_at",
]
)
+4
View File
@@ -21,6 +21,7 @@ from .parser import (
parse_email_addresses,
parse_email_message,
)
from .utils import extract_base64_images_from_html, extract_base64_images_from_text
__all__ = [
# Parser functions
@@ -37,4 +38,7 @@ __all__ = [
"create_reply_message",
"create_forward_message",
"EmailComposeError",
# Utility functions
"extract_base64_images_from_html",
"extract_base64_images_from_text",
]
+146
View File
@@ -0,0 +1,146 @@
"""Utility functions for RFC5322 email processing."""
import base64
import hashlib
import logging
import re
import typing
import uuid
logger = logging.getLogger(__name__)
# Matches src="data:<mime>;base64,<data>" in HTML img tags
_HTML_BASE64_IMG_RE = re.compile(
r'(<img\b[^>]*\bsrc=["\'])data:(image/[a-zA-Z0-9.+-]+);base64,([A-Za-z0-9+/\n\r =]+)(["\'][^>]*>)'
)
# Matches ![alt](data:<mime>;base64,<data>) in markdown text
_MD_BASE64_IMG_RE = re.compile(
r"(!\[[^\]]*\]\()data:(image/[a-zA-Z0-9.+-]+);base64,([A-Za-z0-9+/\n\r =]+)(\))"
)
# Map common image MIME types to file extensions
_MIME_TO_EXT = {
"image/png": "png",
"image/jpeg": "jpg",
"image/gif": "gif",
"image/webp": "webp",
"image/svg+xml": "svg",
}
def _resolve_image(
content: bytes,
content_type: str,
images: list[dict],
known_images: dict[str, str] | None,
) -> str:
"""Return the CID for *content*, reusing an existing one when possible.
If *known_images* is provided and already contains an entry whose SHA-256
digest matches *content*, the existing CID is returned without creating a
duplicate. Otherwise a new image dict is appended to *images* (and
registered in *known_images* if supplied).
"""
digest = hashlib.sha256(content).hexdigest()
if known_images is not None and digest in known_images:
return known_images[digest]
cid = str(uuid.uuid4())
ext = _MIME_TO_EXT.get(content_type)
filename = f"{cid}.{ext}" if ext else cid
images.append(
{
"cid": cid,
"content": content,
"content_type": content_type,
"name": filename,
"size": len(content),
}
)
if known_images is not None:
known_images[digest] = cid
return cid
def _make_replacer(
images: list[dict],
known_images: dict[str, str] | None,
) -> typing.Callable[[re.Match], str]:
"""Build a regex replacement callback shared by both extract functions."""
def _replace(match: re.Match) -> str:
prefix = match.group(1)
content_type = match.group(2)
b64_data = match.group(3)
suffix = match.group(4)
try:
content = base64.b64decode(b64_data)
# pylint: disable=broad-exception-caught
except Exception:
logger.warning("Failed to decode base64 image, leaving as-is")
return match.group(0)
cid = _resolve_image(content, content_type, images, known_images)
return f"{prefix}cid:{cid}{suffix}"
return _replace
def extract_base64_images_from_text(
text: str,
known_images: dict[str, str] | None = None,
) -> tuple[str, list[dict]]:
"""Extract base64 images from plain text and replace them with CID references.
Handles both markdown image syntax `![...](data:image/...;base64,...)`
and any residual HTML `<img src="data:image/...;base64,...">` tags.
Args:
text: The plain text string potentially containing base64 images.
known_images: Optional dict mapping SHA-256 hex digests to CIDs.
When provided, duplicate images are de-duplicated across calls
by reusing the same CID.
Returns:
A tuple of (stripped_text, images) where *images* is a list of dicts
with keys `cid`, `content` (bytes), `content_type`, `name`,
and `size`.
"""
images: list[dict] = []
replace = _make_replacer(images, known_images)
stripped_text = _MD_BASE64_IMG_RE.sub(replace, text)
stripped_text = _HTML_BASE64_IMG_RE.sub(replace, stripped_text)
return stripped_text, images
def extract_base64_images_from_html(
html: str,
known_images: dict[str, str] | None = None,
) -> tuple[str, list[dict]]:
"""Extract base64-encoded images from HTML and replace them with CID references.
For each `<img src="data:image/...;base64,...">` found in *html*, a unique
CID is generated, the `src` attribute is replaced with `cid:<cid>`, and
the decoded binary content is collected.
Args:
html: The HTML string potentially containing base64 images.
known_images: Optional dict mapping SHA-256 hex digests to CIDs.
When provided, duplicate images are de-duplicated across calls
by reusing the same CID.
Returns:
A tuple of (stripped_html, images) where *images* is a list of dicts
with keys `cid`, `content` (bytes), `content_type`, and `name` and `size`.
"""
images: list[dict] = []
stripped_html = _HTML_BASE64_IMG_RE.sub(_make_replacer(images, known_images), html)
return stripped_html, images
+42 -12
View File
@@ -1969,23 +1969,20 @@ class MessageTemplate(BaseModel):
except (json.JSONDecodeError, AttributeError):
return None
def render_template(
self,
mailbox: Mailbox = None,
user: User = None,
context: Optional[Dict[str, Any]] = None,
) -> Dict[str, str]:
@staticmethod
def resolve_placeholder_values(mailbox=None, user=None, message=None):
"""
Render the template with the given context.
Resolve placeholder values from mailbox, user, and message context.
Args:
mailbox: Mailbox object
user: User object
mailbox: Mailbox object — provides `name` via its contact
user: User object — fallback for `name` and source of custom attributes
message: Message object — provides `recipient_name` from TO recipients
Returns:
Dictionary with 'html_body' and 'text_body' keys containing rendered content
Dictionary mapping placeholder keys to their resolved string values
"""
context = context.copy() if context else {}
context = {}
context["name"] = (
mailbox.contact.name
if mailbox and mailbox.contact
@@ -1998,11 +1995,44 @@ class MessageTemplate(BaseModel):
for field_key in schema_properties.keys():
context[field_key] = user.custom_attributes.get(field_key) or ""
if message:
to_recipients = message.recipients.filter(
type=MessageRecipientTypeChoices.TO
).select_related("contact")
context["recipient_name"] = ", ".join(
recipient.contact.name
for recipient in to_recipients
if recipient.contact.name
)
return context
def render_template(
self,
mailbox: Mailbox = None,
user: User = None,
message: Message = None,
) -> Dict[str, str]:
"""
Render the template with the given context.
Args:
mailbox: Mailbox object
user: User object
message: Message object
Returns:
Dictionary with 'html_body' and 'text_body' keys containing rendered content
"""
resolved = self.resolve_placeholder_values(
mailbox=mailbox, user=user, message=message
)
rendered_html_body = self.html_body
rendered_text_body = self.text_body
# Simple placeholder substitution
for key, value in context.items():
for key, value in resolved.items():
placeholder = f"{{{key}}}"
rendered_html_body = rendered_html_body.replace(
placeholder, escape(str(value))
@@ -1,7 +1,10 @@
"""Test create operations for MessageTemplateViewSet."""
# pylint: disable=too-many-lines
import base64
import json
from django.test import override_settings
from django.urls import reverse
import pytest
@@ -417,6 +420,33 @@ class TestAdminMailDomainMessageTemplateCreate:
assert models.MessageTemplate.objects.get().maildomain == maildomain
assert not models.MessageTemplate.objects.get().mailbox
@override_settings(MAX_TEMPLATE_IMAGE_SIZE=100)
def test_create_with_oversized_base64_image(self, user, maildomain, admin_list_url):
"""Creating a template with an oversized base64 image should fail."""
factories.MailDomainAccessFactory(
maildomain=maildomain,
user=user,
role=enums.MailDomainAccessRoleChoices.ADMIN,
)
client = APIClient()
client.force_authenticate(user=user)
large_data = base64.b64encode(b"\x89PNG" + b"\x00" * 200).decode()
html_body = f'<img src="data:image/png;base64,{large_data}">'
data = {
"name": "Template with large image",
"html_body": html_body,
"text_body": "content",
"raw_body": RAW_DATA,
"type": "signature",
}
response = client.post(admin_list_url, data, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "html_body" in response.data
class TestAdminMailDomainMessageTemplateUpdate:
"""Test admin maildomain update operations for MessageTemplateViewSet."""
@@ -883,6 +913,45 @@ class TestAdminMailDomainMessageTemplateUpdate:
assert signature1.is_default is False
assert signature2.is_default is True
@override_settings(MAX_TEMPLATE_IMAGE_SIZE=100)
def test_update_with_oversized_base64_image(
self, user, maildomain, admin_detail_url
):
"""Updating a template with an oversized base64 image should fail."""
factories.MailDomainAccessFactory(
maildomain=maildomain,
user=user,
role=enums.MailDomainAccessRoleChoices.ADMIN,
)
maildomain_template = factories.MessageTemplateFactory(
html_body="<p>Original content</p>",
text_body="Original content",
maildomain=maildomain,
)
client = APIClient()
client.force_authenticate(user=user)
large_data = base64.b64encode(b"\x89PNG" + b"\x00" * 200).decode()
html_body = f'<img src="data:image/png;base64,{large_data}">'
data = {
"name": "Updated Template",
"html_body": html_body,
"text_body": "Updated content",
"raw_body": RAW_DATA,
"type": "signature",
}
response = client.put(
admin_detail_url(maildomain_template.id),
data,
format="json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "html_body" in response.data
class TestAdminMailDomainMessageTemplateDelete:
"""Test delete operations for MessageTemplateViewSet."""
@@ -29,6 +29,7 @@ pytestmark = pytest.mark.django_db
MAX_OUTGOING_BODY_SIZE=5242880, # 5MB
MAX_INCOMING_EMAIL_SIZE=10485760, # 10MB
MAX_RECIPIENTS_PER_MESSAGE=42,
MAX_TEMPLATE_IMAGE_SIZE=2097152, # 2MB
IMAGE_PROXY_ENABLED=False,
MESSAGES_MANUAL_RETRY_MAX_AGE=86400, # 1 day in seconds
)
@@ -57,6 +58,7 @@ def test_api_config(is_authenticated):
"MAX_OUTGOING_ATTACHMENT_SIZE": 20971520,
"MAX_OUTGOING_BODY_SIZE": 5242880,
"MAX_RECIPIENTS_PER_MESSAGE": 42,
"MAX_TEMPLATE_IMAGE_SIZE": 2097152,
"IMAGE_PROXY_ENABLED": False,
"MESSAGES_MANUAL_RETRY_MAX_AGE": 86400,
}
@@ -1,7 +1,9 @@
"""Test CRUD operations for MailboxMessageTemplateViewSet."""
import base64
import json
from django.test import override_settings
from django.urls import reverse
import pytest
@@ -276,6 +278,60 @@ class TestMailboxMessageTemplateCreate:
assert not template.maildomain
assert template.name == "Test Template"
@override_settings(MAX_TEMPLATE_IMAGE_SIZE=100)
def test_create_with_oversized_base64_image(self, user, mailbox, list_url):
"""Creating a template with an oversized base64 image should fail."""
factories.MailboxAccessFactory(
mailbox=mailbox,
user=user,
role=enums.MailboxRoleChoices.ADMIN,
)
client = APIClient()
client.force_authenticate(user=user)
# Generate a base64 image larger than the 100-byte limit
large_data = base64.b64encode(b"\x89PNG" + b"\x00" * 200).decode()
html_body = f'<img src="data:image/png;base64,{large_data}">'
data = {
"name": "Template with large image",
"html_body": html_body,
"text_body": "content",
"raw_body": RAW_DATA,
"type": "signature",
}
response = client.post(list_url, data, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "html_body" in response.data
def test_create_with_valid_base64_image(self, user, mailbox, list_url):
"""Creating a template with a valid-sized base64 image should succeed."""
factories.MailboxAccessFactory(
mailbox=mailbox,
user=user,
role=enums.MailboxRoleChoices.ADMIN,
)
client = APIClient()
client.force_authenticate(user=user)
# Generate a small base64 image well within the 2 MiB default limit
small_data = base64.b64encode(b"\x89PNG" + b"\x00" * 10).decode()
html_body = f'<img src="data:image/png;base64,{small_data}">'
data = {
"name": "Template with small image",
"html_body": html_body,
"text_body": "content",
"raw_body": RAW_DATA,
"type": "signature",
}
response = client.post(list_url, data, format="json")
assert response.status_code == status.HTTP_201_CREATED
class TestMailboxMessageTemplateUpdate:
"""Test update operations for MailboxMessageTemplateViewSet."""
@@ -417,6 +473,39 @@ class TestMailboxMessageTemplateUpdate:
content = json.loads(mailbox_template.blob.get_content().decode("utf-8"))
assert content["raw"] == RAW_DATA_STRUCT
@override_settings(MAX_TEMPLATE_IMAGE_SIZE=100)
def test_update_with_oversized_base64_image(
self, user, mailbox, mailbox_template, detail_url
):
"""Updating a template with an oversized base64 image should fail."""
factories.MailboxAccessFactory(
mailbox=mailbox,
user=user,
role=enums.MailboxRoleChoices.ADMIN,
)
client = APIClient()
client.force_authenticate(user=user)
large_data = base64.b64encode(b"\x89PNG" + b"\x00" * 200).decode()
html_body = f'<img src="data:image/png;base64,{large_data}">'
data = {
"name": "Updated Template",
"html_body": html_body,
"text_body": "Updated content",
"raw_body": RAW_DATA,
"type": "message",
}
response = client.put(
detail_url(mailbox_template.id),
data,
format="json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "html_body" in response.data
class TestMailboxMessageTemplateDelete:
"""Test delete operations for MailboxMessageTemplateViewSet."""
@@ -1,318 +0,0 @@
"""Test render action for MessageTemplateViewSet."""
import uuid
from unittest.mock import patch
from django.urls import reverse
import pytest
from rest_framework import status
from rest_framework.test import APIClient
from core import enums, factories, models
pytestmark = pytest.mark.django_db
@pytest.fixture(name="user")
def fixture_user():
"""Create a test user."""
return factories.UserFactory(
full_name="John Doe", custom_attributes={"job_title": "Adjointe"}
)
@pytest.fixture(name="maildomain")
def fixture_maildomain():
"""Create a test mail domain."""
return factories.MailDomainFactory()
@pytest.fixture(name="mailbox")
def fixture_mailbox():
"""Create a test mailbox."""
return factories.MailboxFactory()
class TestMessageTemplateRender:
"""Test the render_template action."""
def test_unauthorized(self, mailbox, maildomain):
"""Test that unauthorized users cannot render templates."""
# Mailbox template
mailbox_template = factories.MessageTemplateFactory(
name="Mailbox Test Template",
mailbox=mailbox,
)
client = APIClient()
response = client.get(
reverse(
"mailbox-message-templates-render-template",
kwargs={"mailbox_id": mailbox.id, "pk": mailbox_template.id},
)
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
# Maildomain template
maildomain_template = factories.MessageTemplateFactory(
name="Maildomain Test Template",
html_body="<p>Test content</p>",
text_body="Test content",
maildomain=maildomain,
)
client = APIClient()
response = client.get(
reverse(
"mailbox-message-templates-render-template",
kwargs={"mailbox_id": mailbox.id, "pk": maildomain_template.id},
)
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_no_access(self, user, mailbox, maildomain):
"""Test that users without access cannot render templates."""
mailbox_template = factories.MessageTemplateFactory(
name="Mailbox Test Template",
mailbox=mailbox,
)
client = APIClient()
client.force_authenticate(user=user)
response = client.get(
reverse(
"mailbox-message-templates-render-template",
kwargs={"mailbox_id": mailbox.id, "pk": mailbox_template.id},
)
)
assert response.status_code == status.HTTP_403_FORBIDDEN
maildomain_template = factories.MessageTemplateFactory(
name="Maildomain Test Template",
maildomain=maildomain,
)
client = APIClient()
client.force_authenticate(user=user)
response = client.get(
reverse(
"mailbox-message-templates-render-template",
kwargs={"mailbox_id": mailbox.id, "pk": maildomain_template.id},
)
)
assert response.status_code == status.HTTP_403_FORBIDDEN
@patch(
"django.conf.settings.SCHEMA_CUSTOM_ATTRIBUTES_USER",
{"properties": {"job_title": {"type": "string"}}},
)
@pytest.mark.parametrize(
"role",
[
models.MailboxRoleChoices.EDITOR,
models.MailboxRoleChoices.SENDER,
models.MailboxRoleChoices.VIEWER,
models.MailboxRoleChoices.ADMIN,
],
)
def test_success(self, user, mailbox, role):
"""Test successful template rendering."""
# Only create access for mailbox here
factories.MailboxAccessFactory(
mailbox=mailbox,
user=user,
role=role,
)
# Create templates for mailbox and maildomain
mailbox_template = factories.MessageTemplateFactory(
html_body="<p>{name} - {job_title}</p>",
text_body="{name} - {job_title}",
mailbox=mailbox,
)
maildomain_template = factories.MessageTemplateFactory(
html_body="<p>Cordialement, {name} - {job_title}</p>",
text_body="Cordialement, {name} - {job_title}",
maildomain=mailbox.domain,
)
# Create client and authenticate user with access to mailbox
client = APIClient()
client.force_authenticate(user=user)
# Try render of mailbox template
response = client.get(
reverse(
"mailbox-message-templates-render-template",
kwargs={"mailbox_id": mailbox.id, "pk": mailbox_template.id},
)
)
# Every thing should be ok here
assert response.status_code == status.HTTP_200_OK
# The template will be rendered with the user's full name
assert "John Doe - Adjointe" in response.data["html_body"]
assert "John Doe - Adjointe" in response.data["text_body"]
# Try render of maildomain template. User with access
# to a mailbox should have access to the templates of maildomain of mailbox too.
response = client.get(
reverse(
"mailbox-message-templates-render-template",
kwargs={"mailbox_id": mailbox.id, "pk": maildomain_template.id},
)
)
assert response.status_code == status.HTTP_200_OK
assert "Cordialement, John Doe - Adjointe" in response.data["html_body"]
assert "Cordialement, John Doe - Adjointe" in response.data["text_body"]
@patch(
"django.conf.settings.SCHEMA_CUSTOM_ATTRIBUTES_USER",
{"properties": {"job_title": {"type": "string"}}},
)
@pytest.mark.parametrize(
"role",
[
models.MailboxRoleChoices.EDITOR,
models.MailboxRoleChoices.SENDER,
models.MailboxRoleChoices.VIEWER,
models.MailboxRoleChoices.ADMIN,
],
)
def test_success_with_context(self, user, mailbox, role):
"""Test successful template rendering with context."""
factories.MailboxAccessFactory(
mailbox=mailbox,
user=user,
role=role,
)
template_reply = factories.MessageTemplateFactory(
html_body="<p>Hello {recipient_name}!</p><p> My name is {name} and I'm {job_title}.</p>",
text_body="Hello {recipient_name}! My name is {name} and I'm {job_title}.",
mailbox=mailbox,
)
client = APIClient()
client.force_authenticate(user=user)
url = reverse(
"mailbox-message-templates-render-template",
kwargs={"mailbox_id": mailbox.id, "pk": template_reply.id},
)
response = client.get(f"{url}?recipient_name=Jane Smith")
assert response.status_code == status.HTTP_200_OK
assert "Hello Jane Smith!" in response.data["html_body"]
assert "Hello Jane Smith!" in response.data["text_body"]
assert "My name is John Doe and I'm Adjointe." in response.data["html_body"]
assert "My name is John Doe and I'm Adjointe." in response.data["text_body"]
def test_render_template_no_access_mailbox(self, user, mailbox):
"""Test rendering a template from a mailbox that doesn't exist."""
client = APIClient()
client.force_authenticate(user=user)
mailbox_template = factories.MessageTemplateFactory(
name="Mailbox Test Template",
mailbox=mailbox,
)
response = client.get(
reverse(
"mailbox-message-templates-render-template",
kwargs={"mailbox_id": uuid.uuid4(), "pk": mailbox_template.id},
)
)
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_render_template_not_found(self, user, mailbox):
"""Test rendering a non-existent template."""
factories.MailboxAccessFactory(
mailbox=mailbox,
user=user,
role=models.MailboxRoleChoices.VIEWER,
)
client = APIClient()
client.force_authenticate(user=user)
response = client.get(
reverse(
"mailbox-message-templates-render-template",
kwargs={
"mailbox_id": mailbox.id,
"pk": "00000000-0000-0000-0000-000000000000",
},
)
)
# get_object() will return 404 if template doesn't exist or user has no access
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_success_with_placeholders(self, user, mailbox):
"""Test successful template rendering with placeholders."""
factories.MailboxAccessFactory(
mailbox=mailbox,
user=user,
role=models.MailboxRoleChoices.VIEWER,
)
# Create template with valid placeholders
template = factories.MessageTemplateFactory(
name="Placeholder Template",
html_body="<p>Hello {name}!</p>",
text_body="Hello {name}!",
type=enums.MessageTemplateTypeChoices.MESSAGE,
mailbox=mailbox,
)
client = APIClient()
client.force_authenticate(user=user)
response = client.get(
reverse(
"mailbox-message-templates-render-template",
kwargs={"mailbox_id": mailbox.id, "pk": template.id},
)
)
assert response.status_code == status.HTTP_200_OK
# Check that placeholders are replaced
assert user.full_name in response.data["html_body"]
assert user.full_name in response.data["text_body"]
def test_escapes_html(self, user, mailbox):
"""Test that HTML is escaped in the template rendering."""
user.full_name = "<b>Alice & Co.</b>"
user.save()
factories.MailboxAccessFactory(
mailbox=mailbox, user=user, role=models.MailboxRoleChoices.VIEWER
)
template = factories.MessageTemplateFactory(
html_body="<p>{name}</p>", text_body="{name}", mailbox=mailbox
)
client = APIClient()
client.force_authenticate(user=user)
resp = client.get(
reverse(
"mailbox-message-templates-render-template",
kwargs={"mailbox_id": mailbox.id, "pk": template.id},
)
)
assert resp.status_code == status.HTTP_200_OK
assert resp.data["html_body"] == "<p>&lt;b&gt;Alice &amp; Co.&lt;/b&gt;</p>"
assert resp.data["text_body"] == "<b>Alice & Co.</b>"
def test_success_with_shared_email_contact(self, user, mailbox):
"""Test successful template rendering with contact."""
contact = factories.ContactFactory(name="Mairie de Brigny", mailbox=mailbox)
mailbox.contact = contact
mailbox.save()
factories.MailboxAccessFactory(
mailbox=mailbox, user=user, role=models.MailboxRoleChoices.VIEWER
)
template = factories.MessageTemplateFactory(
html_body="<p>{name}</p>", text_body="{name}", mailbox=mailbox
)
client = APIClient()
client.force_authenticate(user=user)
response = client.get(
reverse(
"mailbox-message-templates-render-template",
kwargs={"mailbox_id": mailbox.id, "pk": template.id},
)
)
assert response.status_code == status.HTTP_200_OK
assert response.data["html_body"] == "<p>Mairie de Brigny</p>"
assert response.data["text_body"] == "Mairie de Brigny"
@@ -150,3 +150,90 @@ class TestMessageTemplateRetrieve:
)
assert response.status_code == status.HTTP_200_OK
assert response.data["name"] == template.name
def test_success_maildomain_template(self, user, mailbox):
"""Test retrieving a maildomain template through the mailbox endpoint."""
factories.MailboxAccessFactory(
mailbox=mailbox,
user=user,
role=models.MailboxRoleChoices.VIEWER,
)
template = factories.MessageTemplateFactory(
name="Domain Template",
html_body="<p>Domain content</p>",
text_body="Domain content",
maildomain=mailbox.domain,
)
client = APIClient()
client.force_authenticate(user=user)
response = client.get(
reverse(
"mailbox-message-templates-detail",
kwargs={"mailbox_id": mailbox.id, "pk": template.id},
)
)
assert response.status_code == status.HTTP_200_OK
assert response.data["id"] == str(template.id)
assert response.data["name"] == "Domain Template"
def test_maildomain_template_not_accessible_from_other_domain(self, user):
"""Test that a maildomain template is not accessible from a mailbox on
a different domain."""
mailbox = factories.MailboxFactory()
other_mailbox = factories.MailboxFactory()
factories.MailboxAccessFactory(
mailbox=other_mailbox,
user=user,
role=models.MailboxRoleChoices.VIEWER,
)
template = factories.MessageTemplateFactory(
name="Other Domain Template",
html_body="<p>Other domain content</p>",
text_body="Other domain content",
maildomain=mailbox.domain,
)
client = APIClient()
client.force_authenticate(user=user)
response = client.get(
reverse(
"mailbox-message-templates-detail",
kwargs={"mailbox_id": other_mailbox.id, "pk": template.id},
)
)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_other_mailbox_template_not_accessible(self, user, mailbox):
"""Test that a template belonging to another mailbox on the same domain
is not accessible."""
other_mailbox = factories.MailboxFactory(domain=mailbox.domain)
factories.MailboxAccessFactory(
mailbox=mailbox,
user=user,
role=models.MailboxRoleChoices.VIEWER,
)
template = factories.MessageTemplateFactory(
name="Other Mailbox Template",
html_body="<p>Other mailbox content</p>",
text_body="Other mailbox content",
mailbox=other_mailbox,
)
client = APIClient()
client.force_authenticate(user=user)
response = client.get(
reverse(
"mailbox-message-templates-detail",
kwargs={"mailbox_id": mailbox.id, "pk": template.id},
)
)
assert response.status_code == status.HTTP_404_NOT_FOUND
@@ -0,0 +1,281 @@
"""Test draft message resolve placeholder api endpoint."""
import uuid
from unittest.mock import patch
from django.urls import reverse
import pytest
from rest_framework import status
from rest_framework.test import APIClient
from core import enums, factories, models
pytestmark = pytest.mark.django_db
@pytest.fixture(name="user")
def fixture_user():
"""Create a test user."""
return factories.UserFactory(
full_name="John Doe", custom_attributes={"job_title": "Adjointe"}
)
@pytest.fixture(name="mailbox")
def fixture_mailbox():
"""Create a test mailbox."""
return factories.MailboxFactory()
def _create_draft(mailbox):
"""Create a draft message owned by the given mailbox with thread editor access."""
sender_contact = factories.ContactFactory(
name="Sender", email="sender@example.com", mailbox=mailbox
)
thread = factories.ThreadFactory()
factories.ThreadAccessFactory(
thread=thread,
mailbox=mailbox,
role=enums.ThreadAccessRoleChoices.EDITOR,
)
return factories.MessageFactory(sender=sender_contact, thread=thread, is_draft=True)
def resolve_url(message_id):
"""Build the URL for the draft-placeholders endpoint."""
return reverse("draft-placeholders", kwargs={"message_id": message_id})
class TestResolvePlaceholder:
"""Test the resolve placeholder endpoint under draft/{message_id}/."""
def test_api_draft_placeholder_resolve_unauthorized(self, mailbox):
"""Test that unauthenticated users cannot resolve placeholders."""
draft = _create_draft(mailbox)
client = APIClient()
response = client.get(resolve_url(draft.id))
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_api_draft_placeholder_resolve_no_mailbox_access(self, user, mailbox):
"""Test that users without mailbox access get 404."""
draft = _create_draft(mailbox)
client = APIClient()
client.force_authenticate(user=user)
response = client.get(resolve_url(draft.id))
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_api_draft_placeholder_resolve_viewer_role_denied(self, user, mailbox):
"""Test that VIEWER role on mailbox is not sufficient (need editor-level)."""
draft = _create_draft(mailbox)
factories.MailboxAccessFactory(
mailbox=mailbox, user=user, role=models.MailboxRoleChoices.VIEWER
)
client = APIClient()
client.force_authenticate(user=user)
response = client.get(resolve_url(draft.id))
assert response.status_code == status.HTTP_404_NOT_FOUND
@patch(
"django.conf.settings.SCHEMA_CUSTOM_ATTRIBUTES_USER",
{"properties": {"job_title": {"type": "string"}}},
)
@pytest.mark.parametrize(
"role",
[
models.MailboxRoleChoices.EDITOR,
models.MailboxRoleChoices.SENDER,
models.MailboxRoleChoices.ADMIN,
],
)
def test_api_draft_placeholder_resolve_success_editor_roles(
self, user, mailbox, role
):
"""Test that EDITOR/SENDER/ADMIN roles can resolve placeholders."""
draft = _create_draft(mailbox)
factories.MailboxAccessFactory(mailbox=mailbox, user=user, role=role)
client = APIClient()
client.force_authenticate(user=user)
response = client.get(resolve_url(draft.id))
assert response.status_code == status.HTTP_200_OK
assert response.data["name"] == "John Doe"
assert response.data["job_title"] == "Adjointe"
@patch(
"django.conf.settings.SCHEMA_CUSTOM_ATTRIBUTES_USER",
{"properties": {"job_title": {"type": "string"}}},
)
def test_api_draft_placeholder_resolve_name_from_mailbox_contact(
self, user, mailbox
):
"""Test that name is resolved from mailbox contact when available."""
contact = factories.ContactFactory(
name="Mairie de Brigny", email="mairie@brigny.fr", mailbox=mailbox
)
mailbox.contact = contact
mailbox.save()
draft = _create_draft(mailbox)
factories.MailboxAccessFactory(
mailbox=mailbox, user=user, role=models.MailboxRoleChoices.EDITOR
)
client = APIClient()
client.force_authenticate(user=user)
response = client.get(resolve_url(draft.id))
assert response.status_code == status.HTTP_200_OK
assert response.data["name"] == "Mairie de Brigny"
assert response.data["job_title"] == "Adjointe"
@patch(
"django.conf.settings.SCHEMA_CUSTOM_ATTRIBUTES_USER",
{"properties": {}},
)
def test_api_draft_placeholder_resolve_name_fallback_to_user_full_name(
self, user, mailbox
):
"""Test that name falls back to user full_name when mailbox has no contact."""
draft = _create_draft(mailbox)
factories.MailboxAccessFactory(
mailbox=mailbox, user=user, role=models.MailboxRoleChoices.EDITOR
)
client = APIClient()
client.force_authenticate(user=user)
response = client.get(resolve_url(draft.id))
assert response.status_code == status.HTTP_200_OK
assert response.data["name"] == "John Doe"
@patch(
"django.conf.settings.SCHEMA_CUSTOM_ATTRIBUTES_USER",
{"properties": {}},
)
def test_api_draft_placeholder_resolve_recipient_name_from_to_recipients(
self, user, mailbox
):
"""Test recipient_name resolution from TO recipients of the draft."""
draft = _create_draft(mailbox)
factories.MailboxAccessFactory(
mailbox=mailbox, user=user, role=models.MailboxRoleChoices.EDITOR
)
contact_jane = factories.ContactFactory(
name="Jane Smith", email="jane@example.com", mailbox=mailbox
)
contact_bob = factories.ContactFactory(
name="Bob Martin", email="bob@example.com", mailbox=mailbox
)
factories.MessageRecipientFactory(
message=draft,
contact=contact_jane,
type=enums.MessageRecipientTypeChoices.TO,
)
factories.MessageRecipientFactory(
message=draft,
contact=contact_bob,
type=enums.MessageRecipientTypeChoices.TO,
)
# CC recipient should NOT appear in recipient_name
contact_cc = factories.ContactFactory(
name="CC Person", email="cc@example.com", mailbox=mailbox
)
factories.MessageRecipientFactory(
message=draft,
contact=contact_cc,
type=enums.MessageRecipientTypeChoices.CC,
)
client = APIClient()
client.force_authenticate(user=user)
response = client.get(resolve_url(draft.id))
assert response.status_code == status.HTTP_200_OK
assert "Jane Smith" in response.data["recipient_name"]
assert "Bob Martin" in response.data["recipient_name"]
assert "CC Person" not in response.data["recipient_name"]
def test_api_draft_placeholder_resolve_nonexistent_draft(self, user):
"""Test that a non-existent draft returns 404."""
client = APIClient()
client.force_authenticate(user=user)
response = client.get(resolve_url(uuid.uuid4()))
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_api_draft_placeholder_resolve_non_draft_message_denied(
self, user, mailbox
):
"""Test that a non-draft (sent) message returns 404."""
sender_contact = factories.ContactFactory(
name="Sender", email="sender@test.com", mailbox=mailbox
)
thread = factories.ThreadFactory()
factories.ThreadAccessFactory(
thread=thread,
mailbox=mailbox,
role=enums.ThreadAccessRoleChoices.EDITOR,
)
sent_message = factories.MessageFactory(
sender=sender_contact, thread=thread, is_draft=False
)
factories.MailboxAccessFactory(
mailbox=mailbox, user=user, role=models.MailboxRoleChoices.EDITOR
)
client = APIClient()
client.force_authenticate(user=user)
response = client.get(resolve_url(sent_message.id))
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_api_draft_placeholder_resolve_thread_viewer_access_denied(
self, user, mailbox
):
"""Test that thread VIEWER access (not EDITOR) is denied."""
sender_contact = factories.ContactFactory(
name="Sender", email="sender@viewer.com", mailbox=mailbox
)
thread = factories.ThreadFactory()
# Thread access is VIEWER, not EDITOR
factories.ThreadAccessFactory(
thread=thread,
mailbox=mailbox,
role=enums.ThreadAccessRoleChoices.VIEWER,
)
draft = factories.MessageFactory(
sender=sender_contact, thread=thread, is_draft=True
)
factories.MailboxAccessFactory(
mailbox=mailbox, user=user, role=models.MailboxRoleChoices.EDITOR
)
client = APIClient()
client.force_authenticate(user=user)
response = client.get(resolve_url(draft.id))
assert response.status_code == status.HTTP_404_NOT_FOUND
@patch(
"django.conf.settings.SCHEMA_CUSTOM_ATTRIBUTES_USER",
{
"properties": {
"job_title": {"type": "string"},
"department": {"type": "string"},
}
},
)
def test_api_draft_placeholder_resolve_custom_attributes_empty_values(
self, user, mailbox
):
"""Test that missing custom attributes resolve to empty strings."""
draft = _create_draft(mailbox)
factories.MailboxAccessFactory(
mailbox=mailbox, user=user, role=models.MailboxRoleChoices.EDITOR
)
client = APIClient()
client.force_authenticate(user=user)
response = client.get(resolve_url(draft.id))
assert response.status_code == status.HTTP_200_OK
assert response.data["job_title"] == "Adjointe"
assert response.data["department"] == ""
+178
View File
@@ -1,6 +1,7 @@
"""Tests for the core.mda.outbound module."""
# pylint: disable=unused-argument,too-many-lines
import re
import threading
import time
from unittest.mock import MagicMock, call, patch
@@ -10,6 +11,7 @@ from django.test import TransactionTestCase, override_settings
import dns.resolver
import pytest
import rest_framework as drf
from core import enums, factories, models
from core.mda import outbound
@@ -1051,3 +1053,179 @@ class TestSendMessageDKIMVerification:
# Verify internal delivery was attempted
assert mock_deliver_inbound.called
# 1x1 red pixel PNG, small enough to be used in tests
TINY_PNG_B64 = (
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4"
"2mP8z8BQDwADhQGAWjR9awAAAABJRU5ErkJggg=="
)
@pytest.mark.django_db
class TestPrepareOutboundMessageBase64Images:
"""Test base64 image extraction in prepare_outbound_message."""
def _make_message(self, mailbox_sender, signature=None):
"""Helper to create a draft message with a recipient."""
thread = factories.ThreadFactory()
factories.ThreadAccessFactory(
mailbox=mailbox_sender,
thread=thread,
role=enums.ThreadAccessRoleChoices.EDITOR,
)
sender_contact = factories.ContactFactory(mailbox=mailbox_sender)
message = factories.MessageFactory(
thread=thread,
sender=sender_contact,
is_draft=True,
subject="Test Base64 Images",
signature=signature,
)
# Add a recipient so compose_email succeeds
to_contact = factories.ContactFactory(
mailbox=mailbox_sender, email="to@example.com"
)
factories.MessageRecipientFactory(
message=message,
contact=to_contact,
type=models.MessageRecipientTypeChoices.TO,
)
return message
def test_prepare_outbound_base64_in_signature_converted_to_inline_attachments(
self, mailbox_sender, user, mailbox_access
):
"""Base64 images in the signature are extracted to inline CID attachments."""
sig = factories.MessageTemplateFactory(
name="Sig with image",
html_body=f'<p>Regards</p><img src="data:image/png;base64,{TINY_PNG_B64}">',
text_body="Regards",
type=enums.MessageTemplateTypeChoices.SIGNATURE,
is_active=True,
mailbox=mailbox_sender,
)
message = self._make_message(mailbox_sender, signature=sig)
outbound.prepare_outbound_message(
mailbox_sender, message, "Hello", "<p>Hello</p>", user
)
message.refresh_from_db()
raw = message.blob.get_content().decode(errors="replace")
# The base64 data URI must no longer appear in the body
assert "data:image/png;base64," not in raw
# A CID reference must be present
assert "cid:" in raw
def test_prepare_outbound_base64_in_body_converted_to_inline_attachments(
self, mailbox_sender
):
"""Base64 images in the HTML body itself are extracted to inline CID attachments."""
message = self._make_message(mailbox_sender)
html_body = f'<p>See image:</p><img src="data:image/png;base64,{TINY_PNG_B64}">'
outbound.prepare_outbound_message(
mailbox_sender, message, "See image", html_body
)
message.refresh_from_db()
raw = message.blob.get_content().decode(errors="replace")
assert "data:image/png;base64," not in raw
assert "cid:" in raw
def test_prepare_outbound_base64_has_attachments_set_when_present(
self, mailbox_sender
):
"""has_attachments is True when base64 images are present even without blob attachments."""
message = self._make_message(mailbox_sender)
html_body = f'<img src="data:image/png;base64,{TINY_PNG_B64}">'
outbound.prepare_outbound_message(mailbox_sender, message, "text", html_body)
message.refresh_from_db()
assert message.has_attachments is True
def test_prepare_outbound_base64_has_attachments_false_when_none(
self, mailbox_sender
):
"""has_attachments is False when there are no attachments nor base64 images."""
message = self._make_message(mailbox_sender)
outbound.prepare_outbound_message(
mailbox_sender, message, "Hello", "<p>Hello</p>"
)
message.refresh_from_db()
assert message.has_attachments is False
@override_settings(MAX_OUTGOING_ATTACHMENT_SIZE=10)
def test_prepare_outbound_base64_count_toward_attachment_size_limit(
self, mailbox_sender
):
"""A base64 image whose decoded size exceeds MAX_OUTGOING_ATTACHMENT_SIZE raises ValidationError."""
message = self._make_message(mailbox_sender)
# The tiny PNG decodes to ~69 bytes, well above the 10-byte limit
html_body = f'<img src="data:image/png;base64,{TINY_PNG_B64}">'
with pytest.raises(drf.exceptions.ValidationError) as exc_info:
outbound.prepare_outbound_message(
mailbox_sender, message, "text", html_body
)
assert "attachment size" in str(exc_info.value.detail).lower()
@override_settings(MAX_OUTGOING_ATTACHMENT_SIZE=200)
def test_prepare_outbound_base64_combined_blob_size_validation(
self, mailbox_sender
):
"""Blob attachments + base64 images that together exceed the limit raise ValidationError."""
message = self._make_message(mailbox_sender)
# Create a blob attachment of 150 bytes (under the 200 byte limit alone)
attachment = factories.AttachmentFactory(
mailbox=mailbox_sender,
blob_size=150,
name="file.bin",
)
attachment.messages.add(message)
# The tiny PNG (~69 bytes) + 150 bytes blob > 200 byte limit
html_body = f'<img src="data:image/png;base64,{TINY_PNG_B64}">'
with pytest.raises(drf.exceptions.ValidationError) as exc_info:
outbound.prepare_outbound_message(
mailbox_sender, message, "text", html_body
)
assert "attachment size" in str(exc_info.value.detail).lower()
def test_prepare_outbound_base64_deduplicated_across_text_and_html(
self, mailbox_sender
):
"""The same base64 image in both text and HTML bodies produces only one attachment."""
message = self._make_message(mailbox_sender)
img_data_uri = f"data:image/png;base64,{TINY_PNG_B64}"
text_body = f"![img]({img_data_uri})"
html_body = f'<img src="{img_data_uri}">'
outbound.prepare_outbound_message(mailbox_sender, message, text_body, html_body)
message.refresh_from_db()
raw = message.blob.get_content().decode(errors="replace")
# Both text and HTML bodies should reference the same CID.
# Extract all cid references from the raw MIME.
cid_refs = re.findall(r"cid:([a-zA-Z0-9@._-]+)", raw)
# Deduplicate: all references should point to the same single CID
unique_cids = set(cid_refs)
assert len(unique_cids) == 1, (
f"Expected exactly 1 unique CID (deduplicated), got {len(unique_cids)}: {unique_cids}"
)
# There should be at least 2 references (one in text part, one in HTML part)
assert len(cid_refs) >= 2, (
f"Expected at least 2 CID references (text + HTML), got {len(cid_refs)}"
)
@@ -0,0 +1,263 @@
"""Tests for core.mda.rfc5322.utils — base64 image extraction utilities."""
import base64
import re
from core.mda.rfc5322.utils import (
extract_base64_images_from_html,
extract_base64_images_from_text,
)
# A tiny valid 1x1 red PNG (68 bytes)
_1PX_PNG = (
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01"
b"\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90wS\xde\x00\x00"
b"\x00\x0cIDATx\x9cc\xf8\x0f\x00\x00\x01\x01\x00\x05\x18"
b"\xd8N\x00\x00\x00\x00IEND\xaeB`\x82"
)
_1PX_PNG_B64 = base64.b64encode(_1PX_PNG).decode()
_UUID_RE = re.compile(
r"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
)
class TestExtractBase64Images:
"""Tests for extract_base64_images_from_html()."""
def test_extract_base64_html_no_images(self):
"""HTML without base64 images is returned unchanged."""
html = "<p>Hello world</p>"
result_html, images = extract_base64_images_from_html(html)
assert result_html == html
assert len(images) == 0
def test_extract_base64_html_single_image(self):
"""A single base64 image is extracted and replaced with a CID."""
html = f'<p>Text</p><img src="data:image/png;base64,{_1PX_PNG_B64}" alt="pic">'
result_html, images = extract_base64_images_from_html(html)
assert len(images) == 1
assert images[0]["content"] == _1PX_PNG
assert images[0]["content_type"] == "image/png"
assert images[0]["size"] == len(_1PX_PNG)
assert images[0]["name"].endswith(".png")
# The HTML should reference the CID
assert f'src="cid:{images[0]["cid"]}"' in result_html
assert "data:image" not in result_html
def test_extract_base64_html_multiple_images(self):
"""Multiple base64 images are each extracted with unique CIDs."""
html = (
f'<img src="data:image/png;base64,{_1PX_PNG_B64}">'
f'<img src="data:image/jpeg;base64,{_1PX_PNG_B64}">'
)
result_html, images = extract_base64_images_from_html(html)
assert len(images) == 2
assert images[0]["cid"] != images[1]["cid"]
assert images[0]["content_type"] == "image/png"
assert images[1]["content_type"] == "image/jpeg"
assert "data:image" not in result_html
def test_extract_base64_html_existing_cid_not_touched(self):
"""Images already using cid: references are not modified."""
html = '<img src="cid:existing-uuid">'
result_html, images = extract_base64_images_from_html(html)
assert result_html == html
assert len(images) == 0
def test_extract_base64_html_non_image_data_url_not_touched(self):
"""Non-image data URLs (e.g. text/plain) are left as-is."""
html = '<img src="data:text/plain;base64,SGVsbG8=">'
result_html, images = extract_base64_images_from_html(html)
assert result_html == html
assert len(images) == 0
def test_extract_base64_html_invalid_left_as_is(self):
"""Invalid base64 data leaves the img tag unchanged."""
html = '<img src="data:image/png;base64,!!!invalid!!!">'
result_html, images = extract_base64_images_from_html(html)
assert result_html == html
assert len(images) == 0
def test_extract_base64_html_empty(self):
"""Empty string returns empty string and no images."""
result_html, images = extract_base64_images_from_html("")
assert result_html == ""
assert len(images) == 0
def test_extract_base64_html_mixed_content(self):
"""HTML with both base64 images and regular URLs."""
html = (
f'<img src="data:image/png;base64,{_1PX_PNG_B64}">'
'<img src="https://example.com/photo.jpg">'
'<img src="cid:already-inline">'
)
result_html, images = extract_base64_images_from_html(html)
assert len(images) == 1
assert "https://example.com/photo.jpg" in result_html
assert "cid:already-inline" in result_html
assert "data:image" not in result_html
def test_extract_base64_html_cid_is_valid_uuid(self):
"""Generated CIDs are valid UUID4 strings."""
html = f'<img src="data:image/png;base64,{_1PX_PNG_B64}">'
_, images = extract_base64_images_from_html(html)
assert _UUID_RE.match(images[0]["cid"])
class TestExtractBase64ImagesFromText:
"""Tests for extract_base64_images_from_text()."""
def test_extract_base64_text_no_images(self):
"""Plain text without base64 images is returned unchanged."""
text = "Hello world\nThis is a message."
result, images = extract_base64_images_from_text(text)
assert result == text
assert len(images) == 0
def test_extract_base64_text_single_md_image(self):
"""A single markdown base64 image is replaced with a CID reference."""
text = f"Before\n![logo](data:image/png;base64,{_1PX_PNG_B64})\nAfter"
result, images = extract_base64_images_from_text(text)
assert len(images) == 1
assert images[0]["content"] == _1PX_PNG
assert images[0]["content_type"] == "image/png"
assert images[0]["size"] == len(_1PX_PNG)
assert f"![logo](cid:{images[0]['cid']})" in result
assert "data:image" not in result
assert "Before" in result
assert "After" in result
def test_extract_base64_text_multiple_md_images(self):
"""Multiple markdown base64 images are all replaced with unique CIDs."""
text = (
f"Start\n![a](data:image/png;base64,{_1PX_PNG_B64})\n"
f"Middle\n![b](data:image/jpeg;base64,{_1PX_PNG_B64})\nEnd"
)
result, images = extract_base64_images_from_text(text)
assert len(images) == 2
assert images[0]["cid"] != images[1]["cid"]
assert "data:image" not in result
assert "Start" in result
assert "Middle" in result
assert "End" in result
def test_extract_base64_text_preserves_normal_urls(self):
"""Markdown images with normal URLs are preserved."""
text = "![photo](https://example.com/photo.jpg)"
result, images = extract_base64_images_from_text(text)
assert result == text
assert len(images) == 0
def test_extract_base64_text_mixed_content(self):
"""Only base64 images are replaced; normal content and URLs remain."""
text = (
f"Hello\n![inline](data:image/png;base64,{_1PX_PNG_B64})\n"
"![photo](https://example.com/photo.jpg)\nBye"
)
result, images = extract_base64_images_from_text(text)
assert len(images) == 1
assert "data:image" not in result
assert "![photo](https://example.com/photo.jpg)" in result
assert "Hello" in result
assert "Bye" in result
def test_extract_base64_text_html_img_tag(self):
"""Residual HTML img tags with base64 data are also replaced with CIDs."""
text = f'Some text <img src="data:image/png;base64,{_1PX_PNG_B64}" alt="pic"> more text'
result, images = extract_base64_images_from_text(text)
assert len(images) == 1
assert "data:image" not in result
assert f"cid:{images[0]['cid']}" in result
assert "Some text" in result
assert "more text" in result
def test_extract_base64_text_empty_string(self):
"""Empty string returns empty string and no images."""
result, images = extract_base64_images_from_text("")
assert result == ""
assert len(images) == 0
def test_extract_base64_text_cid_is_valid_uuid(self):
"""Generated CIDs are valid UUID4 strings."""
text = f"![img](data:image/png;base64,{_1PX_PNG_B64})"
_, images = extract_base64_images_from_text(text)
assert _UUID_RE.match(images[0]["cid"])
class TestDeduplication:
"""Tests for cross-body image deduplication via known_images."""
def test_dedup_base64_same_image_in_text_and_html_uses_same_cid(self):
"""The same base64 image in text and HTML produces a single attachment."""
known_images: dict[str, str] = {}
text = f"![logo](data:image/png;base64,{_1PX_PNG_B64})"
text_result, text_images = extract_base64_images_from_text(
text, known_images=known_images
)
html = f'<img src="data:image/png;base64,{_1PX_PNG_B64}">'
html_result, html_images = extract_base64_images_from_html(
html, known_images=known_images
)
# Only one new image should have been created (from the text pass)
assert len(text_images) == 1
assert len(html_images) == 0
# Both bodies reference the same CID
cid = text_images[0]["cid"]
assert f"![logo](cid:{cid})" in text_result
assert f'src="cid:{cid}"' in html_result
def test_dedup_base64_different_images_not_deduplicated(self):
"""Different images produce separate attachments even with known_images."""
# A 1x1 white PNG (different from _1PX_PNG)
other_png = (
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01"
b"\x00\x00\x00\x01\x08\x00\x00\x00\x00:~\x9bU\x00\x00"
b"\x00\nIDATx\x9cc`\x00\x00\x00\x02\x00\x01\xe2!\xbc"
b"3\x00\x00\x00\x00IEND\xaeB`\x82"
)
other_b64 = base64.b64encode(other_png).decode()
known_images: dict[str, str] = {}
text = f"![a](data:image/png;base64,{_1PX_PNG_B64})"
_, text_images = extract_base64_images_from_text(
text, known_images=known_images
)
html = f'<img src="data:image/png;base64,{other_b64}">'
_, html_images = extract_base64_images_from_html(
html, known_images=known_images
)
assert len(text_images) == 1
assert len(html_images) == 1
assert text_images[0]["cid"] != html_images[0]["cid"]
def test_dedup_base64_duplicate_within_same_body(self):
"""The same image appearing twice in one body is also deduplicated."""
known_images: dict[str, str] = {}
text = (
f"![a](data:image/png;base64,{_1PX_PNG_B64})\n"
f"![b](data:image/png;base64,{_1PX_PNG_B64})"
)
result, images = extract_base64_images_from_text(
text, known_images=known_images
)
assert len(images) == 1
cid = images[0]["cid"]
assert f"![a](cid:{cid})" in result
assert f"![b](cid:{cid})" in result
@@ -0,0 +1,216 @@
"""Tests for MessageTemplate model methods."""
from unittest.mock import patch
import pytest
from core import factories, models
pytestmark = pytest.mark.django_db
class TestResolveplaceholderValues:
"""Tests for MessageTemplate.resolve_placeholder_values()."""
def test_resolve_placeholder_name_from_mailbox_contact(self):
"""When a mailbox has a contact, the name should come from the contact."""
mailbox = factories.MailboxFactory()
contact = factories.ContactFactory(name="Mairie de Brigny", mailbox=mailbox)
mailbox.contact = contact
mailbox.save()
user = factories.UserFactory(full_name="John Doe")
result = models.MessageTemplate.resolve_placeholder_values(
mailbox=mailbox, user=user
)
assert result["name"] == "Mairie de Brigny"
def test_resolve_placeholder_name_fallback_to_user_full_name(self):
"""When mailbox has no contact, the name should fallback to user's full_name."""
mailbox = factories.MailboxFactory()
user = factories.UserFactory(full_name="John Doe")
result = models.MessageTemplate.resolve_placeholder_values(
mailbox=mailbox, user=user
)
assert result["name"] == "John Doe"
def test_resolve_placeholder_name_empty_when_no_mailbox_no_user(self):
"""When neither mailbox nor user is provided, name should be empty."""
result = models.MessageTemplate.resolve_placeholder_values()
assert result["name"] == ""
@patch(
"django.conf.settings.SCHEMA_CUSTOM_ATTRIBUTES_USER",
{
"properties": {
"job_title": {"type": "string"},
"department": {"type": "string"},
}
},
)
def test_resolve_placeholder_custom_attributes_from_user(self):
"""Custom attributes defined in schema should be resolved from user."""
user = factories.UserFactory(
custom_attributes={"job_title": "Développeur", "department": "DSI"}
)
result = models.MessageTemplate.resolve_placeholder_values(user=user)
assert result["job_title"] == "Développeur"
assert result["department"] == "DSI"
@patch(
"django.conf.settings.SCHEMA_CUSTOM_ATTRIBUTES_USER",
{"properties": {"job_title": {"type": "string"}}},
)
def test_resolve_placeholder_custom_attributes_missing_defaults_to_empty(self):
"""Missing custom attributes should default to empty string."""
user = factories.UserFactory(custom_attributes={})
result = models.MessageTemplate.resolve_placeholder_values(user=user)
assert result["job_title"] == ""
def test_resolve_placeholder_recipient_name_from_to_recipients(self):
"""recipient_name should be resolved from TO recipients of the message."""
mailbox = factories.MailboxFactory()
message = factories.MessageFactory(
sender=factories.ContactFactory(mailbox=mailbox)
)
factories.MessageRecipientFactory(
message=message,
contact=factories.ContactFactory(name="Alice", mailbox=mailbox),
type=models.MessageRecipientTypeChoices.TO,
)
factories.MessageRecipientFactory(
message=message,
contact=factories.ContactFactory(name="Bob", mailbox=mailbox),
type=models.MessageRecipientTypeChoices.TO,
)
# CC recipients should be excluded
factories.MessageRecipientFactory(
message=message,
contact=factories.ContactFactory(name="Charlie", mailbox=mailbox),
type=models.MessageRecipientTypeChoices.CC,
)
result = models.MessageTemplate.resolve_placeholder_values(message=message)
assert "Alice" in result["recipient_name"]
assert "Bob" in result["recipient_name"]
assert "Charlie" not in result["recipient_name"]
def test_resolve_placeholder_no_message_no_recipient_name(self):
"""When no message is provided, recipient_name should not be in the result."""
result = models.MessageTemplate.resolve_placeholder_values()
assert "recipient_name" not in result
class TestRenderTemplate:
"""Tests for MessageTemplate.render_template()."""
@patch(
"django.conf.settings.SCHEMA_CUSTOM_ATTRIBUTES_USER",
{"properties": {"job_title": {"type": "string"}}},
)
def test_render_template_placeholder_substitution(self):
"""Placeholders should be replaced with resolved values."""
mailbox = factories.MailboxFactory()
user = factories.UserFactory(
full_name="John Doe",
custom_attributes={"job_title": "Adjointe"},
)
template = factories.MessageTemplateFactory(
html_body="<p>{name} - {job_title}</p>",
text_body="{name} - {job_title}",
mailbox=mailbox,
)
result = template.render_template(mailbox=mailbox, user=user)
assert result["html_body"] == "<p>John Doe - Adjointe</p>"
assert result["text_body"] == "John Doe - Adjointe"
def test_render_template_escapes_html_in_html_body(self):
"""HTML special characters in placeholder values must be escaped in html_body."""
mailbox = factories.MailboxFactory()
user = factories.UserFactory(full_name="<b>Alice & Co.</b>")
template = factories.MessageTemplateFactory(
html_body="<p>{name}</p>",
text_body="{name}",
mailbox=mailbox,
)
result = template.render_template(mailbox=mailbox, user=user)
assert result["html_body"] == "<p>&lt;b&gt;Alice &amp; Co.&lt;/b&gt;</p>"
def test_render_template_no_escape_in_text_body(self):
"""Placeholder values should NOT be escaped in text_body."""
mailbox = factories.MailboxFactory()
user = factories.UserFactory(full_name="<b>Alice & Co.</b>")
template = factories.MessageTemplateFactory(
html_body="<p>{name}</p>",
text_body="{name}",
mailbox=mailbox,
)
result = template.render_template(mailbox=mailbox, user=user)
assert result["text_body"] == "<b>Alice & Co.</b>"
def test_render_template_with_mailbox_contact_name(self):
"""When mailbox has a contact, the contact name should be used."""
mailbox = factories.MailboxFactory()
contact = factories.ContactFactory(name="Mairie de Brigny", mailbox=mailbox)
mailbox.contact = contact
mailbox.save()
user = factories.UserFactory(full_name="John Doe")
template = factories.MessageTemplateFactory(
html_body="<p>{name}</p>",
text_body="{name}",
mailbox=mailbox,
)
result = template.render_template(mailbox=mailbox, user=user)
assert result["html_body"] == "<p>Mairie de Brigny</p>"
assert result["text_body"] == "Mairie de Brigny"
def test_render_template_with_recipient_name(self):
"""recipient_name should be resolved from the message's TO recipients."""
mailbox = factories.MailboxFactory()
user = factories.UserFactory(full_name="John Doe")
message = factories.MessageFactory(
sender=factories.ContactFactory(mailbox=mailbox)
)
factories.MessageRecipientFactory(
message=message,
contact=factories.ContactFactory(name="Jane Smith", mailbox=mailbox),
type=models.MessageRecipientTypeChoices.TO,
)
template = factories.MessageTemplateFactory(
html_body="<p>Hello {recipient_name}!</p>",
text_body="Hello {recipient_name}!",
mailbox=mailbox,
)
result = template.render_template(mailbox=mailbox, user=user, message=message)
assert result["html_body"] == "<p>Hello Jane Smith!</p>"
assert result["text_body"] == "Hello Jane Smith!"
def test_render_template_unresolved_placeholders_remain(self):
"""Placeholders without a resolved value should remain as-is."""
mailbox = factories.MailboxFactory()
user = factories.UserFactory(full_name="John Doe")
template = factories.MessageTemplateFactory(
html_body="<p>{name} - {unknown_field}</p>",
text_body="{name} - {unknown_field}",
mailbox=mailbox,
)
result = template.render_template(mailbox=mailbox, user=user)
assert "{unknown_field}" in result["html_body"]
assert "{unknown_field}" in result["text_body"]
assert "John Doe" in result["html_body"]
@@ -1 +0,0 @@
# This file can be empty or contain other model tests if needed.
+6 -1
View File
@@ -36,7 +36,7 @@ from core.api.viewsets.metrics import (
MailboxUsageMetricsApiView,
MailDomainUsersMetricsApiView,
)
from core.api.viewsets.placeholder import PlaceholderView
from core.api.viewsets.placeholder import DraftPlaceholderView, PlaceholderView
from core.api.viewsets.send import SendMessageView
from core.api.viewsets.task import TaskDetailView
from core.api.viewsets.thread import ThreadViewSet
@@ -201,6 +201,11 @@ urlpatterns = [
DraftMessageView.as_view(),
name="draft-message-detail",
),
path(
f"api/{settings.API_VERSION}/draft/<uuid:message_id>/placeholders/",
DraftPlaceholderView.as_view(),
name="draft-placeholders",
),
path(
f"api/{settings.API_VERSION}/send/",
SendMessageView.as_view(),
+13 -1
View File
@@ -2,7 +2,7 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-02-03 18:09+0000\n"
"POT-Creation-Date: 2026-02-11 17:50+0000\n"
"PO-Revision-Date: 2025-01-27 09:27\n"
"Last-Translator: \n"
"Language-Team: English\n"
@@ -92,6 +92,10 @@ msgstr ""
msgid "A mailbox with this local part already exists in this domain."
msgstr ""
#, python-format
msgid "Image \"%(name)s\" (%(size)s MB) exceeds the %(max)s MB limit."
msgstr ""
msgid "Name"
msgstr ""
@@ -350,9 +354,17 @@ msgstr ""
msgid "has delivery pending"
msgstr ""
msgid ""
"True if thread has messages awaiting successful delivery (sending, retrying, "
"or failed)."
msgstr ""
msgid "has delivery failed"
msgstr ""
msgid "True if thread has messages with permanent delivery failure."
msgstr ""
msgid "messaged at"
msgstr ""
Binary file not shown.
+14 -1
View File
@@ -2,7 +2,7 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-messages\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-02-03 18:09+0000\n"
"POT-Creation-Date: 2026-02-11 17:50+0000\n"
"PO-Revision-Date: 2026-02-03 17:00\n"
"Last-Translator: \n"
"Language-Team: French\n"
@@ -96,6 +96,11 @@ msgstr "Erreur"
msgid "A mailbox with this local part already exists in this domain."
msgstr "Une boîte avec ce préfixe existe déjà dans ce domaine."
#, python-format
msgid "Image \"%(name)s\" (%(size)s MB) exceeds the %(max)s MB limit."
msgstr ""
"L'image « %(name)s » (%(size)s Mo) dépasse la limite de %(max)s Mo."
msgid "Name"
msgstr "Nom"
@@ -382,9 +387,17 @@ msgstr "a des actifs"
msgid "has delivery pending"
msgstr "a des messages en cours d'envoi"
msgid ""
"True if thread has messages awaiting successful delivery (sending, retrying, "
"or failed)."
msgstr "Vrai si la conversation a des messages en attente de livraison (envoi, réexpédition, échec)."
msgid "has delivery failed"
msgstr "a des messages en échec de livraison"
msgid "True if thread has messages with permanent delivery failure."
msgstr "Vrai si la conversation a des messages avec un échec de livraison permanent."
msgid "messaged at"
msgstr "dernier message à"
+6
View File
@@ -105,6 +105,12 @@ class Base(Configuration):
environ_prefix=None,
)
MAX_TEMPLATE_IMAGE_SIZE = values.PositiveIntegerValue(
2 * 1024 * 1024, # 2 MiB
environ_name="MAX_TEMPLATE_IMAGE_SIZE",
environ_prefix=None,
)
MAX_OUTGOING_BODY_SIZE = values.PositiveIntegerValue(
5 * 1024 * 1024, # 5 MiB
environ_name="MAX_OUTGOING_BODY_SIZE",
@@ -109,6 +109,7 @@
"Cancel": "Cancel",
"Cancel those sendings": "Cancel those sendings",
"Cannot add attachment(s). Total size would be more than {{maxSize}}.": "Cannot add attachment(s). Total size would be more than {{maxSize}}.",
"Cannot add image. File size exceeds the {{maxSize}} limit.": "Cannot add image. File size exceeds the {{maxSize}} limit.",
"CC: ": "CC: ",
"Check DNS again": "Check DNS again",
"Checking DNS records...": "Checking DNS records...",
@@ -248,6 +249,7 @@
"How to allow IMAP connections from your account {{name}}?": "How to allow IMAP connections from your account {{name}}?",
"I confirm that this address corresponds to the real identity of a colleague, and I commit to deactivating it when their position ends.": "I confirm that this address corresponds to the real identity of a colleague, and I commit to deactivating it when their position ends.",
"Identity": "Identity",
"Image size limit exceeded": "Image size limit exceeded",
"IMAP port": "IMAP port",
"IMAP server": "IMAP server",
"IMAP server is required.": "IMAP server is required.",
@@ -345,7 +347,6 @@
"No subject": "No subject",
"No summary available.": "No summary available.",
"No template found": "No template found",
"No templates available": "No templates available",
"No threads.": "No threads.",
"Open {{driveAppName}} preview": "Open {{driveAppName}} preview",
"Open filters": "Open filters",
@@ -134,6 +134,7 @@
"Cancel": "Annuler",
"Cancel those sendings": "Annuler ces envois",
"Cannot add attachment(s). Total size would be more than {{maxSize}}.": "Impossible d'ajouter ces pièces jointes. La taille totale dépasserait la limite autorisée de {{maxSize}}.",
"Cannot add image. File size exceeds the {{maxSize}} limit.": "Impossible d'ajouter l'image. La taille du fichier dépasse la limite de {{maxSize}}.",
"CC: ": "Copie : ",
"Check DNS again": "Revérifier les DNS",
"Checking DNS records...": "Vérification des enregistrements DNS...",
@@ -278,6 +279,7 @@
"How to allow IMAP connections from your account {{name}}?": "Comment autoriser les connexions IMAP depuis votre compte {{name}} ?",
"I confirm that this address corresponds to the real identity of a colleague, and I commit to deactivating it when their position ends.": "Je confirme que cette adresse correspond à l'identité d'une personne physique travaillant avec moi, et m'engage à la désactiver quand son poste prendra fin.",
"Identity": "Identifiant",
"Image size limit exceeded": "Taille de l'image trop grande",
"IMAP port": "Port IMAP",
"IMAP server": "Serveur IMAP",
"IMAP server is required.": "Le serveur IMAP est requis.",
@@ -378,7 +380,6 @@
"No subject": "Aucun objet",
"No summary available.": "Aucun résumé disponible.",
"No template found": "Aucun modèle trouvé",
"No templates available": "Aucun modèle disponible",
"No threads.": "Aucune conversation.",
"Open {{driveAppName}} preview": "Ouvrir l'aperçu dans {{driveAppName}}",
"Open filters": "Ouvrir les filtres",
@@ -27,8 +27,6 @@ import type {
MailboxesImageProxyListParams,
MailboxesMessageTemplatesAvailableListParams,
MailboxesMessageTemplatesListParams,
MailboxesMessageTemplatesRenderRetrieve200,
MailboxesMessageTemplatesRenderRetrieveParams,
MailboxesSearchListParams,
MessageTemplate,
MessageTemplateRequest,
@@ -664,7 +662,7 @@ export function useMailboxesMessageTemplatesList<
}
/**
* ViewSet for retrieving and rendering message templates for a mailbox.
* ViewSet for managing message templates for a mailbox.
*/
export type mailboxesMessageTemplatesCreateResponse201 = {
data: MessageTemplate;
@@ -769,7 +767,7 @@ export const useMailboxesMessageTemplatesCreate = <
return useMutation(mutationOptions, queryClient);
};
/**
* ViewSet for retrieving and rendering message templates for a mailbox.
* ViewSet for managing message templates for a mailbox.
*/
export type mailboxesMessageTemplatesRetrieveResponse200 = {
data: MessageTemplate;
@@ -973,7 +971,7 @@ export function useMailboxesMessageTemplatesRetrieve<
}
/**
* ViewSet for retrieving and rendering message templates for a mailbox.
* ViewSet for managing message templates for a mailbox.
*/
export type mailboxesMessageTemplatesUpdateResponse200 = {
data: MessageTemplate;
@@ -1082,7 +1080,7 @@ export const useMailboxesMessageTemplatesUpdate = <
return useMutation(mutationOptions, queryClient);
};
/**
* ViewSet for retrieving and rendering message templates for a mailbox.
* ViewSet for managing message templates for a mailbox.
*/
export type mailboxesMessageTemplatesPartialUpdateResponse200 = {
data: MessageTemplate;
@@ -1196,7 +1194,7 @@ export const useMailboxesMessageTemplatesPartialUpdate = <
return useMutation(mutationOptions, queryClient);
};
/**
* ViewSet for retrieving and rendering message templates for a mailbox.
* ViewSet for managing message templates for a mailbox.
*/
export type mailboxesMessageTemplatesDestroyResponse204 = {
data: void;
@@ -1300,245 +1298,6 @@ export const useMailboxesMessageTemplatesDestroy = <
return useMutation(mutationOptions, queryClient);
};
/**
* Render a template with the provided context variables.
*/
export type mailboxesMessageTemplatesRenderRetrieveResponse200 = {
data: MailboxesMessageTemplatesRenderRetrieve200;
status: 200;
};
export type mailboxesMessageTemplatesRenderRetrieveResponse404 = {
data: void;
status: 404;
};
export type mailboxesMessageTemplatesRenderRetrieveResponseSuccess =
mailboxesMessageTemplatesRenderRetrieveResponse200 & {
headers: Headers;
};
export type mailboxesMessageTemplatesRenderRetrieveResponseError =
mailboxesMessageTemplatesRenderRetrieveResponse404 & {
headers: Headers;
};
export type mailboxesMessageTemplatesRenderRetrieveResponse =
| mailboxesMessageTemplatesRenderRetrieveResponseSuccess
| mailboxesMessageTemplatesRenderRetrieveResponseError;
export const getMailboxesMessageTemplatesRenderRetrieveUrl = (
mailboxId: string,
id: string,
params?: MailboxesMessageTemplatesRenderRetrieveParams,
) => {
const normalizedParams = new URLSearchParams();
Object.entries(params || {}).forEach(([key, value]) => {
if (value !== undefined) {
normalizedParams.append(key, value === null ? "null" : value.toString());
}
});
const stringifiedParams = normalizedParams.toString();
return stringifiedParams.length > 0
? `/api/v1.0/mailboxes/${mailboxId}/message-templates/${id}/render/?${stringifiedParams}`
: `/api/v1.0/mailboxes/${mailboxId}/message-templates/${id}/render/`;
};
export const mailboxesMessageTemplatesRenderRetrieve = async (
mailboxId: string,
id: string,
params?: MailboxesMessageTemplatesRenderRetrieveParams,
options?: RequestInit,
): Promise<mailboxesMessageTemplatesRenderRetrieveResponse> => {
return fetchAPI<mailboxesMessageTemplatesRenderRetrieveResponse>(
getMailboxesMessageTemplatesRenderRetrieveUrl(mailboxId, id, params),
{
...options,
method: "GET",
},
);
};
export const getMailboxesMessageTemplatesRenderRetrieveQueryKey = (
mailboxId?: string,
id?: string,
params?: MailboxesMessageTemplatesRenderRetrieveParams,
) => {
return [
`/api/v1.0/mailboxes/${mailboxId}/message-templates/${id}/render/`,
...(params ? [params] : []),
] as const;
};
export const getMailboxesMessageTemplatesRenderRetrieveQueryOptions = <
TData = Awaited<ReturnType<typeof mailboxesMessageTemplatesRenderRetrieve>>,
TError = void,
>(
mailboxId: string,
id: string,
params?: MailboxesMessageTemplatesRenderRetrieveParams,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof mailboxesMessageTemplatesRenderRetrieve>>,
TError,
TData
>
>;
request?: SecondParameter<typeof fetchAPI>;
},
) => {
const { query: queryOptions, request: requestOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ??
getMailboxesMessageTemplatesRenderRetrieveQueryKey(mailboxId, id, params);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof mailboxesMessageTemplatesRenderRetrieve>>
> = ({ signal }) =>
mailboxesMessageTemplatesRenderRetrieve(mailboxId, id, params, {
signal,
...requestOptions,
});
return {
queryKey,
queryFn,
enabled: !!(mailboxId && id),
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof mailboxesMessageTemplatesRenderRetrieve>>,
TError,
TData
> & { queryKey: DataTag<QueryKey, TData, TError> };
};
export type MailboxesMessageTemplatesRenderRetrieveQueryResult = NonNullable<
Awaited<ReturnType<typeof mailboxesMessageTemplatesRenderRetrieve>>
>;
export type MailboxesMessageTemplatesRenderRetrieveQueryError = void;
export function useMailboxesMessageTemplatesRenderRetrieve<
TData = Awaited<ReturnType<typeof mailboxesMessageTemplatesRenderRetrieve>>,
TError = void,
>(
mailboxId: string,
id: string,
params: undefined | MailboxesMessageTemplatesRenderRetrieveParams,
options: {
query: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof mailboxesMessageTemplatesRenderRetrieve>>,
TError,
TData
>
> &
Pick<
DefinedInitialDataOptions<
Awaited<ReturnType<typeof mailboxesMessageTemplatesRenderRetrieve>>,
TError,
Awaited<ReturnType<typeof mailboxesMessageTemplatesRenderRetrieve>>
>,
"initialData"
>;
request?: SecondParameter<typeof fetchAPI>;
},
queryClient?: QueryClient,
): DefinedUseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useMailboxesMessageTemplatesRenderRetrieve<
TData = Awaited<ReturnType<typeof mailboxesMessageTemplatesRenderRetrieve>>,
TError = void,
>(
mailboxId: string,
id: string,
params?: MailboxesMessageTemplatesRenderRetrieveParams,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof mailboxesMessageTemplatesRenderRetrieve>>,
TError,
TData
>
> &
Pick<
UndefinedInitialDataOptions<
Awaited<ReturnType<typeof mailboxesMessageTemplatesRenderRetrieve>>,
TError,
Awaited<ReturnType<typeof mailboxesMessageTemplatesRenderRetrieve>>
>,
"initialData"
>;
request?: SecondParameter<typeof fetchAPI>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useMailboxesMessageTemplatesRenderRetrieve<
TData = Awaited<ReturnType<typeof mailboxesMessageTemplatesRenderRetrieve>>,
TError = void,
>(
mailboxId: string,
id: string,
params?: MailboxesMessageTemplatesRenderRetrieveParams,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof mailboxesMessageTemplatesRenderRetrieve>>,
TError,
TData
>
>;
request?: SecondParameter<typeof fetchAPI>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useMailboxesMessageTemplatesRenderRetrieve<
TData = Awaited<ReturnType<typeof mailboxesMessageTemplatesRenderRetrieve>>,
TError = void,
>(
mailboxId: string,
id: string,
params?: MailboxesMessageTemplatesRenderRetrieveParams,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof mailboxesMessageTemplatesRenderRetrieve>>,
TError,
TData
>
>;
request?: SecondParameter<typeof fetchAPI>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
} {
const queryOptions = getMailboxesMessageTemplatesRenderRetrieveQueryOptions(
mailboxId,
id,
params,
options,
);
const query = useQuery(queryOptions, queryClient) as UseQueryResult<
TData,
TError
> & { queryKey: DataTag<QueryKey, TData, TError> };
query.queryKey = queryOptions.queryKey;
return query;
}
/**
* List message templates.
*/
@@ -30,6 +30,7 @@ import type {
DraftCreate403,
DraftCreate404,
DraftMessageRequestRequest,
DraftPlaceholdersRetrieve200,
DraftUpdate2400,
DraftUpdate2403,
DraftUpdate2404,
@@ -664,6 +665,208 @@ export const useDraftUpdate2 = <
return useMutation(mutationOptions, queryClient);
};
/**
* Resolve placeholder values for the authenticated user in the context of a draft message. The mailbox is derived from the draft's sender. recipient_name is resolved from the draft's TO recipients.
* @summary Resolve placeholder values for a draft
*/
export type draftPlaceholdersRetrieveResponse200 = {
data: DraftPlaceholdersRetrieve200;
status: 200;
};
export type draftPlaceholdersRetrieveResponse404 = {
data: unknown;
status: 404;
};
export type draftPlaceholdersRetrieveResponseSuccess =
draftPlaceholdersRetrieveResponse200 & {
headers: Headers;
};
export type draftPlaceholdersRetrieveResponseError =
draftPlaceholdersRetrieveResponse404 & {
headers: Headers;
};
export type draftPlaceholdersRetrieveResponse =
| draftPlaceholdersRetrieveResponseSuccess
| draftPlaceholdersRetrieveResponseError;
export const getDraftPlaceholdersRetrieveUrl = (messageId: string) => {
return `/api/v1.0/draft/${messageId}/placeholders/`;
};
export const draftPlaceholdersRetrieve = async (
messageId: string,
options?: RequestInit,
): Promise<draftPlaceholdersRetrieveResponse> => {
return fetchAPI<draftPlaceholdersRetrieveResponse>(
getDraftPlaceholdersRetrieveUrl(messageId),
{
...options,
method: "GET",
},
);
};
export const getDraftPlaceholdersRetrieveQueryKey = (messageId?: string) => {
return [`/api/v1.0/draft/${messageId}/placeholders/`] as const;
};
export const getDraftPlaceholdersRetrieveQueryOptions = <
TData = Awaited<ReturnType<typeof draftPlaceholdersRetrieve>>,
TError = unknown,
>(
messageId: string,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof draftPlaceholdersRetrieve>>,
TError,
TData
>
>;
request?: SecondParameter<typeof fetchAPI>;
},
) => {
const { query: queryOptions, request: requestOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getDraftPlaceholdersRetrieveQueryKey(messageId);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof draftPlaceholdersRetrieve>>
> = ({ signal }) =>
draftPlaceholdersRetrieve(messageId, { signal, ...requestOptions });
return {
queryKey,
queryFn,
enabled: !!messageId,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof draftPlaceholdersRetrieve>>,
TError,
TData
> & { queryKey: DataTag<QueryKey, TData, TError> };
};
export type DraftPlaceholdersRetrieveQueryResult = NonNullable<
Awaited<ReturnType<typeof draftPlaceholdersRetrieve>>
>;
export type DraftPlaceholdersRetrieveQueryError = unknown;
export function useDraftPlaceholdersRetrieve<
TData = Awaited<ReturnType<typeof draftPlaceholdersRetrieve>>,
TError = unknown,
>(
messageId: string,
options: {
query: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof draftPlaceholdersRetrieve>>,
TError,
TData
>
> &
Pick<
DefinedInitialDataOptions<
Awaited<ReturnType<typeof draftPlaceholdersRetrieve>>,
TError,
Awaited<ReturnType<typeof draftPlaceholdersRetrieve>>
>,
"initialData"
>;
request?: SecondParameter<typeof fetchAPI>;
},
queryClient?: QueryClient,
): DefinedUseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useDraftPlaceholdersRetrieve<
TData = Awaited<ReturnType<typeof draftPlaceholdersRetrieve>>,
TError = unknown,
>(
messageId: string,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof draftPlaceholdersRetrieve>>,
TError,
TData
>
> &
Pick<
UndefinedInitialDataOptions<
Awaited<ReturnType<typeof draftPlaceholdersRetrieve>>,
TError,
Awaited<ReturnType<typeof draftPlaceholdersRetrieve>>
>,
"initialData"
>;
request?: SecondParameter<typeof fetchAPI>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useDraftPlaceholdersRetrieve<
TData = Awaited<ReturnType<typeof draftPlaceholdersRetrieve>>,
TError = unknown,
>(
messageId: string,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof draftPlaceholdersRetrieve>>,
TError,
TData
>
>;
request?: SecondParameter<typeof fetchAPI>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
/**
* @summary Resolve placeholder values for a draft
*/
export function useDraftPlaceholdersRetrieve<
TData = Awaited<ReturnType<typeof draftPlaceholdersRetrieve>>,
TError = unknown,
>(
messageId: string,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof draftPlaceholdersRetrieve>>,
TError,
TData
>
>;
request?: SecondParameter<typeof fetchAPI>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
} {
const queryOptions = getDraftPlaceholdersRetrieveQueryOptions(
messageId,
options,
);
const query = useQuery(queryOptions, queryClient) as UseQueryResult<
TData,
TError
> & { queryKey: DataTag<QueryKey, TData, TError> };
query.queryKey = queryOptions.queryKey;
return query;
}
/**
* ViewSet for Message model.
*/
@@ -29,6 +29,8 @@ export type ConfigRetrieve200 = {
readonly MAX_INCOMING_EMAIL_SIZE: number;
/** Maximum number of recipients per message (to + cc + bcc) */
readonly MAX_RECIPIENTS_PER_MESSAGE: number;
/** Maximum size in bytes for images embedded in templates and signatures */
readonly MAX_TEMPLATE_IMAGE_SIZE: number;
/** Whether external images should be proxied */
readonly IMAGE_PROXY_ENABLED: boolean;
/** Maximum age in seconds for a message to be eligible for manual retry of failed deliveries */
@@ -6,7 +6,7 @@
* OpenAPI spec version: 1.0.0 (v1.0)
*/
export type MailboxesMessageTemplatesRenderRetrieve200 = {
html_body?: string;
text_body?: string;
};
/**
* Placeholder keys mapped to their resolved values
*/
export type DraftPlaceholdersRetrieve200 = { [key: string]: string };
@@ -30,6 +30,7 @@ export * from "./draft_create403";
export * from "./draft_create404";
export * from "./draft_message_request_request";
export * from "./draft_message_request_request_attachments_item";
export * from "./draft_placeholders_retrieve200";
export * from "./draft_update2400";
export * from "./draft_update2403";
export * from "./draft_update2404";
@@ -74,8 +75,6 @@ export * from "./mailboxes_message_templates_available_list_params";
export * from "./mailboxes_message_templates_available_list_type";
export * from "./mailboxes_message_templates_list_params";
export * from "./mailboxes_message_templates_list_type_item";
export * from "./mailboxes_message_templates_render_retrieve200";
export * from "./mailboxes_message_templates_render_retrieve_params";
export * from "./mailboxes_search_list_params";
export * from "./maildomain_access_read";
export * from "./maildomain_access_write";
@@ -1,14 +0,0 @@
/**
* Generated by orval 🍺
* Do not edit manually.
* messages API
* This is the messages API schema.
* OpenAPI spec version: 1.0.0 (v1.0)
*/
export type MailboxesMessageTemplatesRenderRetrieveParams = {
/**
* Any other parameter will be available in the template context
*/
"*"?: string;
};
@@ -0,0 +1,16 @@
import { useFormContext } from 'react-hook-form';
/**
* Hidden form inputs for htmlBody, textBody and rawBody.
* Shared by SignatureComposer and TemplateComposer.
*/
export const BodyHiddenInputs = () => {
const form = useFormContext();
return (
<>
<input {...form.register("htmlBody")} type="hidden" />
<input {...form.register("textBody")} type="hidden" />
<input {...form.register("rawBody")} type="hidden" />
</>
);
};
@@ -0,0 +1,602 @@
import { vi } from 'vitest';
import type { Block, InlineContent, StyledText } from '@blocknote/core';
import { EmailExporter } from './index';
vi.mock('@/features/utils/mail-helper', () => ({
default: {
replaceBlobUrlsWithCid: (url: string) => url,
},
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type AnyBlock = Block<any, any, any>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type AnyStyledText = StyledText<any>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type AnyInlineContent = InlineContent<any, any>;
// ---------------------------------------------------------------------------
// Block factories
// ---------------------------------------------------------------------------
function styledText(
text: string,
styles: Record<string, unknown> = {},
): AnyStyledText {
return { type: 'text', text, styles } as AnyStyledText;
}
function link(href: string, text: string): AnyInlineContent {
return {
type: 'link',
href,
content: [styledText(text)],
} as unknown as AnyInlineContent;
}
function paragraph(
content: AnyInlineContent[] | string,
props: Record<string, unknown> = {},
children: AnyBlock[] = [],
): AnyBlock {
const inlineContent =
typeof content === 'string' ? [styledText(content)] : content;
return {
id: crypto.randomUUID(),
type: 'paragraph',
props: { textAlignment: 'left', textColor: 'default', backgroundColor: 'default', ...props },
content: inlineContent,
children,
} as AnyBlock;
}
function heading(
content: AnyInlineContent[] | string,
level: number,
props: Record<string, unknown> = {},
children: AnyBlock[] = [],
): AnyBlock {
const inlineContent =
typeof content === 'string' ? [styledText(content)] : content;
return {
id: crypto.randomUUID(),
type: 'heading',
props: { level, textAlignment: 'left', textColor: 'default', backgroundColor: 'default', ...props },
content: inlineContent,
children,
} as AnyBlock;
}
function image(
url: string,
props: Record<string, unknown> = {},
): AnyBlock {
return {
id: crypto.randomUUID(),
type: 'image',
props: { url, caption: '', name: '', textAlignment: 'left', ...props },
content: undefined,
children: [],
} as unknown as AnyBlock;
}
function bulletListItem(
content: AnyInlineContent[] | string,
props: Record<string, unknown> = {},
children: AnyBlock[] = [],
): AnyBlock {
const inlineContent =
typeof content === 'string' ? [styledText(content)] : content;
return {
id: crypto.randomUUID(),
type: 'bulletListItem',
props: { textAlignment: 'left', textColor: 'default', backgroundColor: 'default', ...props },
content: inlineContent,
children,
} as AnyBlock;
}
function numberedListItem(
content: AnyInlineContent[] | string,
props: Record<string, unknown> = {},
children: AnyBlock[] = [],
): AnyBlock {
const inlineContent =
typeof content === 'string' ? [styledText(content)] : content;
return {
id: crypto.randomUUID(),
type: 'numberedListItem',
props: { textAlignment: 'left', textColor: 'default', backgroundColor: 'default', ...props },
content: inlineContent,
children,
} as AnyBlock;
}
function checkListItem(
content: AnyInlineContent[] | string,
checked: boolean,
props: Record<string, unknown> = {},
children: AnyBlock[] = [],
): AnyBlock {
const inlineContent =
typeof content === 'string' ? [styledText(content)] : content;
return {
id: crypto.randomUUID(),
type: 'checkListItem',
props: { checked, textAlignment: 'left', textColor: 'default', backgroundColor: 'default', ...props },
content: inlineContent,
children,
} as AnyBlock;
}
function codeBlock(
content: AnyInlineContent[] | string,
): AnyBlock {
const inlineContent =
typeof content === 'string' ? [styledText(content)] : content;
return {
id: crypto.randomUUID(),
type: 'codeBlock',
props: {},
content: inlineContent,
children: [],
} as AnyBlock;
}
function quote(
content: AnyInlineContent[] | string,
props: Record<string, unknown> = {},
): AnyBlock {
const inlineContent =
typeof content === 'string' ? [styledText(content)] : content;
return {
id: crypto.randomUUID(),
type: 'quote',
props: { textAlignment: 'left', textColor: 'default', backgroundColor: 'default', ...props },
content: inlineContent,
children: [],
} as AnyBlock;
}
function divider(): AnyBlock {
return {
id: crypto.randomUUID(),
type: 'divider',
props: {},
content: undefined,
children: [],
} as unknown as AnyBlock;
}
function block(
type: string,
content?: AnyInlineContent[] | string,
props: Record<string, unknown> = {},
): AnyBlock {
const inlineContent =
content === undefined
? undefined
: typeof content === 'string'
? [styledText(content)]
: content;
return {
id: crypto.randomUUID(),
type,
props,
content: inlineContent,
children: [],
} as unknown as AnyBlock;
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('EmailExporter', () => {
const exporter = new EmailExporter();
function exportBlocks(blocks: AnyBlock[]): string {
return exporter.exportBlocks(blocks, null);
}
// -----------------------------------------------------------------------
// 1. Paragraph
// -----------------------------------------------------------------------
describe('paragraph', () => {
it('renders simple text in a <p> with margin:0', () => {
const html = exportBlocks([paragraph('Hello world')]);
expect(html).toContain('<p');
expect(html).toContain('margin:0');
expect(html).toContain('Hello world');
});
it('renders empty paragraph as <br>', () => {
const html = exportBlocks([paragraph([])]);
expect(html).toContain('<br/>');
});
it('renders center alignment', () => {
const html = exportBlocks([
paragraph('Centered', { textAlignment: 'center' }),
]);
expect(html).toContain('text-align:center');
});
it('renders textColor', () => {
const html = exportBlocks([
paragraph('Red text', { textColor: 'red' }),
]);
expect(html).toContain('color:#e03e3e');
});
it('renders backgroundColor', () => {
const html = exportBlocks([
paragraph('Highlighted', { backgroundColor: 'yellow' }),
]);
expect(html).toContain('background-color:#fbf3db');
});
});
// -----------------------------------------------------------------------
// 3. Heading
// -----------------------------------------------------------------------
describe('heading', () => {
it('renders level 1 as <h1>', () => {
const html = exportBlocks([heading('Title', 1)]);
expect(html).toContain('<h1');
expect(html).toContain('Title');
});
it('renders level 2 as <h2>', () => {
const html = exportBlocks([heading('Subtitle', 2)]);
expect(html).toContain('<h2');
});
it('renders level 3 as <h3>', () => {
const html = exportBlocks([heading('Section', 3)]);
expect(html).toContain('<h3');
});
it('applies block-level styles', () => {
const html = exportBlocks([
heading('Colored heading', 1, { textColor: 'blue' }),
]);
expect(html).toContain('color:#0b6e99');
});
});
// -----------------------------------------------------------------------
// 4. Inline styles
// -----------------------------------------------------------------------
describe('inline styles', () => {
it('renders bold text', () => {
const html = exportBlocks([
paragraph([styledText('Bold', { bold: true })]),
]);
expect(html).toContain('font-weight:bold');
expect(html).toContain('Bold');
});
it('renders italic text', () => {
const html = exportBlocks([
paragraph([styledText('Italic', { italic: true })]),
]);
expect(html).toContain('font-style:italic');
});
it('renders underline text', () => {
const html = exportBlocks([
paragraph([styledText('Underlined', { underline: true })]),
]);
expect(html).toContain('text-decoration-line:underline');
});
it('renders strikethrough text', () => {
const html = exportBlocks([
paragraph([styledText('Struck', { strike: true })]),
]);
expect(html).toContain('text-decoration-line:line-through');
});
it('renders code inline style', () => {
const html = exportBlocks([
paragraph([styledText('const x', { code: true })]),
]);
expect(html).toContain('font-family:monospace');
});
it('renders combined bold + italic', () => {
const html = exportBlocks([
paragraph([styledText('BoldItalic', { bold: true, italic: true })]),
]);
expect(html).toContain('font-weight:bold');
expect(html).toContain('font-style:italic');
});
it('merges underline + strikethrough into a single text-decoration-line', () => {
const html = exportBlocks([
paragraph([
styledText('Both', { underline: true, strike: true }),
]),
]);
expect(html).toContain('text-decoration-line:underline line-through');
});
it('renders named textColor via COLORS', () => {
const html = exportBlocks([
paragraph([styledText('Purple', { textColor: 'purple' })]),
]);
expect(html).toContain('color:#6940a5');
});
it('renders named backgroundColor via COLORS', () => {
const html = exportBlocks([
paragraph([styledText('Green bg', { backgroundColor: 'green' })]),
]);
expect(html).toContain('background-color:#ddedea');
});
it('passes through non-named color values', () => {
const html = exportBlocks([
paragraph([styledText('Custom', { textColor: '#ff00ff' })]),
]);
expect(html).toContain('color:#ff00ff');
});
it('ignores default color values', () => {
const html = exportBlocks([
paragraph([styledText('Default', { textColor: 'default', backgroundColor: 'default' })]),
]);
// Should render plain text without a <span> wrapper since styles are empty
expect(html).toContain('Default');
expect(html).not.toContain('color:');
});
});
// -----------------------------------------------------------------------
// 5. Links
// -----------------------------------------------------------------------
describe('links', () => {
it('renders a simple link with href and underline', () => {
const html = exportBlocks([
paragraph([link('https://example.com', 'Click here')]),
]);
expect(html).toContain('<a');
expect(html).toContain('href="https://example.com"');
expect(html).toContain('text-decoration:underline');
expect(html).toContain('Click here');
});
it('renders a link with styled text', () => {
const styledLink: AnyInlineContent = {
type: 'link',
href: 'https://example.com',
content: [styledText('Bold link', { bold: true })],
} as unknown as AnyInlineContent;
const html = exportBlocks([paragraph([styledLink])]);
expect(html).toContain('font-weight:bold');
expect(html).toContain('href="https://example.com"');
});
});
// -----------------------------------------------------------------------
// 6. Images
// -----------------------------------------------------------------------
describe('images', () => {
it('renders a simple image with src and alt', () => {
const html = exportBlocks([
image('https://example.com/photo.jpg', { name: 'photo' }),
]);
expect(html).toContain('<img');
expect(html).toContain('src="https://example.com/photo.jpg"');
expect(html).toContain('alt="photo"');
});
it('renders image with caption as <figure> + <figcaption>', () => {
const html = exportBlocks([
image('https://example.com/photo.jpg', { caption: 'A nice photo' }),
]);
expect(html).toContain('<figure');
expect(html).toContain('<figcaption>');
expect(html).toContain('A nice photo');
});
it('renders center alignment with auto margins', () => {
const html = exportBlocks([
image('https://example.com/photo.jpg', { textAlignment: 'center' }),
]);
expect(html).toContain('margin-left:auto');
expect(html).toContain('margin-right:auto');
});
it('renders right alignment with margin-left:auto', () => {
const html = exportBlocks([
image('https://example.com/photo.jpg', { textAlignment: 'right' }),
]);
expect(html).toContain('margin-left:auto');
expect(html).not.toContain('margin-right:auto');
});
it('renders previewWidth as width attribute', () => {
const html = exportBlocks([
image('https://example.com/photo.jpg', { previewWidth: 300 }),
]);
expect(html).toContain('width="300"');
});
it('does not render when url is empty', () => {
const html = exportBlocks([
image(''),
]);
expect(html).not.toContain('<img');
});
});
// -----------------------------------------------------------------------
// 7. Lists
// -----------------------------------------------------------------------
describe('lists', () => {
it('groups consecutive bullet list items in <ul>', () => {
const html = exportBlocks([
bulletListItem('Item A'),
bulletListItem('Item B'),
]);
expect(html).toContain('<ul>');
expect(html).toContain('<li');
expect(html).toContain('Item A');
expect(html).toContain('Item B');
});
it('groups consecutive numbered list items in <ol>', () => {
const html = exportBlocks([
numberedListItem('First'),
numberedListItem('Second'),
]);
expect(html).toContain('<ol>');
expect(html).toContain('First');
expect(html).toContain('Second');
});
it('renders checked check list item with checked input', () => {
const html = exportBlocks([
checkListItem('Done', true),
]);
expect(html).toContain('<input');
expect(html).toContain('checked');
});
it('renders unchecked check list item without checked attribute', () => {
const html = exportBlocks([
checkListItem('Todo', false),
]);
expect(html).toContain('<input');
// The input should not have checked="" attribute
expect(html).not.toMatch(/<input[^>]*checked/);
});
it('positions checkbox in the marker area with negative margin-left', () => {
const html = exportBlocks([
checkListItem('Task', false),
]);
expect(html).toContain('margin-left:-20px');
});
it('renders nested lists from children', () => {
const html = exportBlocks([
bulletListItem('Parent', {}, [
bulletListItem('Child'),
]),
]);
// Nested children should generate a second <ul> within the parent <li>
const ulCount = (html.match(/<ul>/g) || []).length;
expect(ulCount).toBe(2);
expect(html).toContain('Parent');
expect(html).toContain('Child');
});
});
// -----------------------------------------------------------------------
// 8. Code block
// -----------------------------------------------------------------------
describe('code block', () => {
it('renders <pre> + <code>', () => {
const html = exportBlocks([codeBlock('console.log("hello")')]);
expect(html).toContain('<pre');
expect(html).toContain('<code>');
expect(html).toContain('console.log(&quot;hello&quot;)');
});
});
// -----------------------------------------------------------------------
// 9. Quote
// -----------------------------------------------------------------------
describe('quote', () => {
it('renders <blockquote> with border-left', () => {
const html = exportBlocks([quote('A wise thought')]);
expect(html).toContain('<blockquote');
expect(html).toContain('border-left');
expect(html).toContain('A wise thought');
});
});
// -----------------------------------------------------------------------
// 10. Divider
// -----------------------------------------------------------------------
describe('divider', () => {
it('renders <hr> with margin:12px 0', () => {
const html = exportBlocks([divider()]);
expect(html).toContain('<hr');
expect(html).toContain('margin:12px 0');
});
});
// -----------------------------------------------------------------------
// 11. Special blocks
// -----------------------------------------------------------------------
describe('special blocks', () => {
it('does not render table block', () => {
const html = exportBlocks([
block('table'),
]);
expect(html).not.toContain('<table>');
});
it('renders signature as empty <span>', () => {
const html = exportBlocks([block('signature')]);
expect(html).toContain('<span');
});
it('renders quoted-message as empty <span>', () => {
const html = exportBlocks([block('quoted-message')]);
expect(html).toContain('<span');
});
it('renders unknown block with content as <div>', () => {
const html = exportBlocks([
block('custom-block', 'Some content'),
]);
expect(html).toContain('<div>');
expect(html).toContain('Some content');
});
it('does not render unknown block without content', () => {
const html = exportBlocks([block('empty-block')]);
// Should not produce any visible element
expect(html).not.toContain('<div>');
expect(html).not.toContain('empty-block');
});
});
// -----------------------------------------------------------------------
// Golden snapshots — full HTML reference to detect structural changes
// -----------------------------------------------------------------------
describe('golden snapshots', () => {
it('renders a paragraph with styled text', () => {
const html = exportBlocks([
paragraph([
styledText('Hello '),
styledText('world', { bold: true }),
]),
]);
expect(html).toMatchInlineSnapshot(`"<p style="font-size:14px;line-height:24px;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">Hello <span style="font-weight:bold">world</span></p>"`);
});
it('renders a heading with block-level color', () => {
const html = exportBlocks([
heading('Important', 2, { textColor: 'red' }),
]);
expect(html).toMatchInlineSnapshot(`"<h2 style="color:#e03e3e">Important</h2>"`);
});
it('renders an image with caption and center alignment', () => {
const html = exportBlocks([
image('https://example.com/photo.jpg', {
caption: 'A nice photo',
textAlignment: 'center',
previewWidth: 400,
}),
]);
expect(html).toMatchInlineSnapshot(`"<figure style="margin:0;text-align:center"><img loading="lazy" alt="A nice photo" src="https://example.com/photo.jpg" style="display:block;outline:none;border:none;text-decoration:none;margin-left:auto;margin-right:auto" width="400"/><figcaption>A nice photo</figcaption></figure>"`);
});
});
});
@@ -0,0 +1,378 @@
import React, { CSSProperties } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import type { Block, InlineContent, StyledText } from '@blocknote/core';
import { Text, Heading, Img, Link, Hr } from '@react-email/components';
import MailHelper from '@/features/utils/mail-helper';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type AnyBlock = Block<any, any, any>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type AnyInlineContent = InlineContent<any, any>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type AnyStyledText = StyledText<any>;
// Inline copy of COLORS_DEFAULT from @blocknote/core (not part of the public API)
const COLORS: Record<string, { text: string; background: string }> = {
gray: { text: '#9b9a97', background: '#ebeced' },
brown: { text: '#64473a', background: '#e9e5e3' },
red: { text: '#e03e3e', background: '#fbe4e4' },
orange: { text: '#d9730d', background: '#f6e9d9' },
yellow: { text: '#dfab01', background: '#fbf3db' },
green: { text: '#4d6461', background: '#ddedea' },
blue: { text: '#0b6e99', background: '#ddebf1' },
purple: { text: '#6940a5', background: '#eae4f2' },
pink: { text: '#ad1a72', background: '#f4dfeb' },
};
// ---------------------------------------------------------------------------
// Style utilities
// ---------------------------------------------------------------------------
function mergeStyles(styles: CSSProperties[]): CSSProperties {
const merged: CSSProperties = {};
const textDecorations: string[] = [];
for (const style of styles) {
const { textDecorationLine, ...rest } = style;
Object.assign(merged, rest);
if (textDecorationLine) {
textDecorations.push(textDecorationLine as string);
}
}
if (textDecorations.length > 0) {
merged.textDecorationLine = textDecorations.join(' ');
}
return merged;
}
function mapStyle(key: string, value: boolean | string): CSSProperties {
switch (key) {
case 'bold':
return value ? { fontWeight: 'bold' } : {};
case 'italic':
return value ? { fontStyle: 'italic' } : {};
case 'underline':
return value ? { textDecorationLine: 'underline' } : {};
case 'strike':
return value ? { textDecorationLine: 'line-through' } : {};
case 'code':
return value
? {
fontFamily: 'monospace',
backgroundColor: '#f0f0f0',
padding: '2px 4px',
borderRadius: '3px',
}
: {};
case 'textColor':
if (typeof value === 'string' && value !== 'default') {
return { color: COLORS[value]?.text || value };
}
return {};
case 'backgroundColor':
if (typeof value === 'string' && value !== 'default') {
return { backgroundColor: COLORS[value]?.background || value };
}
return {};
default:
return {};
}
}
function inlineStylesToCSS(styles: Record<string, unknown>): CSSProperties {
const cssArray = Object.entries(styles)
.filter(([, value]) => value !== undefined && value !== false)
.map(([key, value]) => mapStyle(key, value as boolean | string));
return mergeStyles(cssArray);
}
function blockPropsToCSS(props: Record<string, unknown>): CSSProperties {
const style: CSSProperties = {};
const alignment = props.textAlignment as string | undefined;
if (alignment && alignment !== 'left') {
style.textAlign = alignment as CSSProperties['textAlign'];
}
const textColor = props.textColor as string | undefined;
if (textColor && textColor !== 'default') {
style.color = COLORS[textColor]?.text || textColor;
}
const bgColor = props.backgroundColor as string | undefined;
if (bgColor && bgColor !== 'default') {
style.backgroundColor = COLORS[bgColor]?.background || bgColor;
}
return style;
}
function styleOrUndefined(style: CSSProperties): CSSProperties | undefined {
return Object.keys(style).length > 0 ? style : undefined;
}
// ---------------------------------------------------------------------------
// Inline content rendering
// ---------------------------------------------------------------------------
function renderStyledText(st: AnyStyledText, key: number): React.ReactNode {
const style = inlineStylesToCSS(st.styles);
if (Object.keys(style).length === 0) {
return st.text;
}
return <span key={key} style={style}>{st.text}</span>;
}
function renderInlineContent(content: AnyInlineContent[]): React.ReactNode[] {
return content.map((ic, i) => {
if (ic.type === 'text') {
return renderStyledText(ic as AnyStyledText, i);
}
if (ic.type === 'link') {
// BlockNote Link: { type: "link", href: string, content: StyledText[] }
const link = ic as { type: 'link'; href: string; content: AnyStyledText[] };
return (
<Link key={i} href={link.href} style={{ color: '#0b6e99', textDecoration: 'underline' }}>
{link.content.map((st, j) => renderStyledText(st, j))}
</Link>
);
}
if (ic.type === 'template-variable') {
const variable = ic as unknown as { props: Record<string, string> };
return <span key={i} data-inline-content-type="template-variable">{`{${variable.props.value}}`}</span>;
}
return null;
});
}
function isContentEmpty(content: AnyInlineContent[] | undefined): boolean {
if (!content || content.length === 0) return true;
return content.every(
(ic) => ic.type === 'text' && !(ic as AnyStyledText).text,
);
}
// ---------------------------------------------------------------------------
// Block rendering
// ---------------------------------------------------------------------------
type ListTag = 'ul' | 'ol';
function getListTag(blockType: string): ListTag | null {
switch (blockType) {
case 'bulletListItem':
case 'checkListItem':
case 'toggleListItem':
return 'ul';
case 'numberedListItem':
return 'ol';
default:
return null;
}
}
function renderListItem(
block: AnyBlock,
editorDomElement: HTMLElement | null,
nestedContent: React.ReactNode[] | null,
key: number,
): React.ReactNode {
const props = block.props as Record<string, unknown>;
const style = blockPropsToCSS(props);
const content = block.content as AnyInlineContent[] | undefined;
if (block.type === 'checkListItem') {
const checked = (props.checked as boolean) || false;
return (
<li key={key} style={{ ...style, listStyleType: 'none' }}>
{/* Apply a negative margin to the checkbox to position it in the marker area (mimic list-style-position: outside) */}
<input type="checkbox" defaultChecked={checked} disabled style={{ marginLeft: '-20px', marginRight: '4px' }} />
{renderInlineContent(content || [])}
{nestedContent}
</li>
);
}
return (
<li key={key} style={styleOrUndefined(style)}>
{renderInlineContent(content || [])}
{nestedContent}
</li>
);
}
function renderBlock(
block: AnyBlock,
editorDomElement: HTMLElement | null,
key: number,
): React.ReactNode {
const props = block.props as Record<string, unknown>;
const style = blockPropsToCSS(props);
const content = block.content as AnyInlineContent[] | undefined;
switch (block.type) {
case 'paragraph': {
if (isContentEmpty(content)) {
return <Text key={key} style={{ margin: 0, ...style }}><br /></Text>;
}
return (
<Text key={key} style={{ margin: 0, ...style }}>
{renderInlineContent(content!)}
</Text>
);
}
case 'heading': {
const level = Math.min(Math.max((props.level as number) || 1, 1), 6);
const as = `h${level}` as 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
return (
<Heading key={key} as={as} style={styleOrUndefined(style)}>
{renderInlineContent(content || [])}
</Heading>
);
}
case 'image': {
const url = props.url as string;
if (!url) return null;
const cidUrl = MailHelper.replaceBlobUrlsWithCid(url);
const imgStyle: CSSProperties = {};
// Resolve width from previewWidth or from the editor DOM
let width = props.previewWidth as number | undefined;
if (!width && editorDomElement) {
const imgEl = editorDomElement.querySelector<HTMLImageElement>(
`[data-id="${block.id}"] img`,
);
if (imgEl?.complete && imgEl.naturalWidth > 0) {
width = imgEl.naturalWidth;
}
}
// Alignment via margin (Img already sets display:block)
const alignment = props.textAlignment as string | undefined;
if (alignment === 'center') {
imgStyle.marginLeft = 'auto';
imgStyle.marginRight = 'auto';
} else if (alignment === 'right') {
imgStyle.marginLeft = 'auto';
}
const caption = props.caption as string | undefined;
const imgNode = (
<Img
src={cidUrl}
alt={caption || (props.name as string) || ''}
width={width}
style={styleOrUndefined(imgStyle)}
loading="lazy"
/>
);
if (caption) {
return (
<figure key={key} style={{ margin: '0', textAlign: (alignment as CSSProperties['textAlign']) || undefined }}>
{imgNode}
<figcaption>{caption}</figcaption>
</figure>
);
}
return React.cloneElement(imgNode, { key });
}
case 'codeBlock': {
return (
<pre key={key} style={{ backgroundColor: '#f5f5f5', padding: '12px', borderRadius: '4px', overflowX: 'auto' }}>
<code>{renderInlineContent(content || [])}</code>
</pre>
);
}
case 'quote': {
return (
<blockquote key={key} style={{ borderLeft: '3px solid #ccc', paddingLeft: '12px', margin: '8px 0', ...style }}>
{renderInlineContent(content || [])}
</blockquote>
);
}
case 'divider': {
return <Hr key={key} style={{ margin: '12px 0' }} />;
}
case 'signature':
case 'quoted-message':
return <span key={key} />;
default:
if (content && content.length > 0) {
return <div key={key}>{renderInlineContent(content)}</div>;
}
return null;
}
}
// ---------------------------------------------------------------------------
// Block tree → React node list (groups consecutive list items)
// ---------------------------------------------------------------------------
function transformBlocks(
blocks: AnyBlock[],
editorDomElement: HTMLElement | null,
): React.ReactNode[] {
const result: React.ReactNode[] = [];
let i = 0;
while (i < blocks.length) {
const block = blocks[i];
const listTag = getListTag(block.type);
if (listTag) {
const listItems: React.ReactNode[] = [];
const startI = i;
while (i < blocks.length && getListTag(blocks[i].type) === listTag) {
const item = blocks[i];
const nested = item.children?.length > 0
? transformBlocks(item.children, editorDomElement)
: null;
listItems.push(renderListItem(item, editorDomElement, nested, i));
i++;
}
const ListTag = listTag;
result.push(<ListTag key={`list-${startI}`}>{listItems}</ListTag>);
} else {
result.push(renderBlock(block, editorDomElement, i));
if (block.children?.length > 0) {
result.push(...transformBlocks(block.children, editorDomElement));
}
i++;
}
}
return result;
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Exports BlockNote blocks to email-safe HTML with inline styles.
*
* Unlike BlockNote's built-in `blocksToHTMLLossy`, the output uses inline
* styles (font-weight, font-style, etc.) that email clients can render,
* and replaces blob download URLs with cid: references for inline images.
*/
export class EmailExporter {
exportBlocks(blocks: AnyBlock[], editorDomElement: HTMLElement | null): string {
const nodes = transformBlocks(blocks, editorDomElement);
return renderToStaticMarkup(<>{nodes}</>);
}
}
@@ -0,0 +1,122 @@
import { BlockNoteSchema, BlockNoteEditor, BlockNoteEditorOptions, BlockSchemaFromSpecs, InlineContentSchemaFromSpecs, StyleSchemaFromSpecs, BlockSpecs, InlineContentSpecs, StyleSpecs, PartialBlock } from '@blocknote/core';
import { useCreateBlockNote } from '@blocknote/react';
import { Extension } from '@tiptap/core';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { useUploadImageAsBase64 } from '@/features/blocknote/image-block/use-upload-image-as-base64';
import { useImageObjectUrls } from '@/features/blocknote/image-block/use-image-object-urls';
import { EmailExporter } from '@/features/blocknote/email-exporter';
import { useConfig } from '@/features/providers/config';
import MailHelper from '@/features/utils/mail-helper';
import { createBlockNoteDictionary, createNonImageFileBlockers } from '@/features/blocknote/utils';
import { handle } from '@/features/utils/errors';
const emailExporter = new EmailExporter();
type UseBase64ComposerOptions<
B extends BlockSpecs,
I extends InlineContentSpecs,
S extends StyleSpecs,
> = {
schema: BlockNoteSchema<BlockSchemaFromSpecs<B>, InlineContentSchemaFromSpecs<I>, StyleSchemaFromSpecs<S>>;
defaultValue?: string | null;
blockNoteOptions?: Partial<BlockNoteEditorOptions<BlockSchemaFromSpecs<B>, InlineContentSchemaFromSpecs<I>, StyleSchemaFromSpecs<S>>>;
trailingBlock?: boolean;
extensions?: Extension[];
};
/**
* Hook encapsulating the shared logic between SignatureComposer and
* TemplateComposer: base64 image upload pipeline, initial content
* parsing (data URLs to Object URLs), editor creation with i18n and
* non-image file blockers, and form synchronisation on change.
*/
export const useBase64Composer = <
B extends BlockSpecs,
I extends InlineContentSpecs,
S extends StyleSpecs,
>({
schema,
defaultValue,
blockNoteOptions,
trailingBlock = true,
extensions,
}: UseBase64ComposerOptions<B, I, S>) => {
const { t, i18n } = useTranslation();
const form = useFormContext();
const config = useConfig();
const baseUploadFile = useUploadImageAsBase64(config.MAX_TEMPLATE_IMAGE_SIZE);
const { createObjectUrl, resolveObjectUrls } = useImageObjectUrls();
const editorRef = useRef<BlockNoteEditor<BlockSchemaFromSpecs<B>, InlineContentSchemaFromSpecs<I>, StyleSchemaFromSpecs<S>>>(null);
const uploadFile = useCallback(async (file: File, blockId?: string) => {
const base64 = await baseUploadFile(file);
if (base64 === null) {
if (blockId) {
// Schedule removal after BlockNote's updateBlock completes.
// We can't remove synchronously because updateBlock would
// throw "Block not found", and we can't throw because
// handleFileInsertion doesn't catch (unhandled rejection).
setTimeout(() => editorRef.current?.removeBlocks([blockId]), 0);
}
return '';
}
return createObjectUrl(file, base64);
}, [baseUploadFile, createObjectUrl]);
const initialContent = useMemo(() => {
const DEFAULT_CONTENT = [{ type: "paragraph", content: "" }];
if (!defaultValue) return DEFAULT_CONTENT;
try {
const blocks = JSON.parse(defaultValue);
return blocks.map((block: Record<string, unknown>, i: number) => {
const props = block.props as Record<string, string> | undefined;
if (block.type === 'image' && props?.url?.startsWith('data:')) {
const file = MailHelper.dataUrlToFile(props.url, `image-${i}.png`);
if (file) {
return { ...block, props: { ...props, url: createObjectUrl(file, props.url) } };
}
}
return block;
});
} catch (error) {
handle(new Error("Error parsing initial content."), { extra: { error, defaultValue } });
return DEFAULT_CONTENT;
}
}, [defaultValue, createObjectUrl]);
const locale = i18n.resolvedLanguage?.split('-')[0] || 'en';
const nonImageFileBlockers = createNonImageFileBlockers();
const editor = useCreateBlockNote({
schema,
tabBehavior: "prefer-navigate-ui",
initialContent: initialContent as PartialBlock<BlockSchemaFromSpecs<B>, InlineContentSchemaFromSpecs<I>, StyleSchemaFromSpecs<S>>[],
trailingBlock,
uploadFile,
dictionary: createBlockNoteDictionary(locale, t),
...blockNoteOptions,
_tiptapOptions: {
...(extensions ? { extensions } : {}),
editorProps: {
handleDOMEvents: nonImageFileBlockers,
},
},
}, [i18n.resolvedLanguage]);
const handleChange = useCallback(() => {
form.setValue("rawBody", resolveObjectUrls(JSON.stringify(editor.document)), { shouldDirty: true });
}, [editor, form, resolveObjectUrls]);
useEffect(() => {
handleChange();
}, []);
useEffect(() => {
editorRef.current = editor;
}, [editor]);
return { editor, handleChange };
};
@@ -1,5 +1,4 @@
import { defaultBlockSpecs } from '@blocknote/core';
import MailHelper from '@/features/utils/mail-helper';
export const ALLOWED_IMAGE_MIME_TYPES = [
'image/jpeg',
@@ -10,11 +9,6 @@ export const ALLOWED_IMAGE_MIME_TYPES = [
// Override the default image block to:
// - Restrict accepted MIME types (affects file picker and drag & drop routing)
// - Fix external HTML export to fit our email needs: BlockNote's imageToExternalHTML omits
// addDefaultPropsExternalHTML (missing alignment styles) and does not resolve
// the natural width of images rendered in the editor.
// - Replace blob download URLs with cid: references for email embedding.
const defaultImageToExternalHTML = defaultBlockSpecs.image.implementation.toExternalHTML;
export const imageBlockSpec: typeof defaultBlockSpecs.image = {
...defaultBlockSpecs.image,
@@ -24,41 +18,5 @@ export const imageBlockSpec: typeof defaultBlockSpecs.image = {
...defaultBlockSpecs.image.implementation.meta,
fileBlockAccept: ALLOWED_IMAGE_MIME_TYPES,
},
toExternalHTML(block, editor, context) {
const result = defaultImageToExternalHTML?.call(this, block, editor, context);
if (!result) return result;
// After wrapInBlockStructure, result.dom is the bn-block-content wrapper.
// Its firstElementChild is the actual exported element (<img> or <figure>).
const target = result.dom.firstElementChild as HTMLElement;
if (!target) return result;
const exportedImg = target.tagName === 'IMG'
? target as HTMLImageElement
: target.querySelector('img');
// --- Blob URL → CID ---
// Replace blob download URLs with cid: references so email clients
// resolve images from the MIME multipart/related structure.
if (exportedImg) {
exportedImg.src = MailHelper.replaceBlobUrlsWithCid(exportedImg.src);
}
// --- Preview width ---
// Resolve the natural width from the editor DOM
// when the image block has not previewWidth set so the exported <img>
// carries a width attribute (used by email clients to size the image).
// This avoids having to enrich block props before calling blocksToHTMLLossy.
if (exportedImg && block.props.url && !block.props.previewWidth) {
const imgEl = editor.domElement?.querySelector<HTMLImageElement>(
`[data-id="${block.id}"] img`,
);
if (imgEl?.complete && imgEl.naturalWidth > 0) {
exportedImg.width = imgEl.naturalWidth;
}
}
return result;
},
},
};
@@ -0,0 +1,57 @@
import { useEffect, useMemo, useRef } from 'react';
import MailHelper from "@/features/utils/mail-helper";
/**
* Replaces base64 data URLs with lightweight Object URLs in sanitized HTML.
* This avoids bloating the DOM with large base64 strings (e.g. ~2.6MB per image)
* while keeping the visual rendering identical.
*
* Object URLs are revoked when the input HTML changes or on unmount.
*/
export const useHtmlWithObjectUrls = (
html: string | null,
): string | null => {
const activeUrlsRef = useRef<string[]>([]);
const { processedHtml, createdUrls } = useMemo(() => {
if (!html) return { processedHtml: null, createdUrls: [] as string[] };
const urls: string[] = [];
let imageIndex = 0;
const result = html.replace(
/src="(data:image\/[^"]+)"/g,
(fullMatch, dataUrl: string) => {
const file = MailHelper.dataUrlToFile(dataUrl, `sig-img-${imageIndex++}`);
if (!file) return fullMatch;
const objectUrl = URL.createObjectURL(file);
urls.push(objectUrl);
return `src="${objectUrl}"`;
},
);
return { processedHtml: result, createdUrls: urls };
}, [html]);
// Revoke previous Object URLs when the input HTML changes
useEffect(() => {
const previousUrls = activeUrlsRef.current;
activeUrlsRef.current = createdUrls;
return () => {
for (const url of previousUrls) {
URL.revokeObjectURL(url);
}
};
}, [createdUrls]);
// Revoke all Object URLs on unmount
useEffect(() => () => {
for (const url of activeUrlsRef.current) {
URL.revokeObjectURL(url);
}
}, []);
return processedHtml;
};
@@ -0,0 +1,48 @@
import { useCallback, useEffect, useRef } from 'react';
interface UseImageObjectUrlsReturn {
/** Create an Object URL for a file, storing the mapping objectUrl→base64 */
createObjectUrl: (file: File, base64DataUrl: string) => string;
/** Replace all Object URLs with their base64 counterparts in a string */
resolveObjectUrls: (content: string) => string;
}
/**
* Manages a bidirectional mapping between short Object URLs and large base64
* data URLs. This allows BlockNote editors to work with lightweight ~60-char
* Object URLs internally, while resolving them back to base64 only when
* persisting form values — avoiding expensive string operations on every
* keystroke.
*/
export const useImageObjectUrls = (): UseImageObjectUrlsReturn => {
const mapRef = useRef<Map<string, string>>(new Map());
const createObjectUrl = useCallback(
(file: File, base64DataUrl: string): string => {
const objectUrl = URL.createObjectURL(file);
mapRef.current.set(objectUrl, base64DataUrl);
return objectUrl;
},
[],
);
const resolveObjectUrls = useCallback((content: string): string => {
let resolved = content;
for (const [objectUrl, base64] of mapRef.current) {
resolved = resolved.replaceAll(objectUrl, base64);
}
return resolved;
}, []);
useEffect(() => {
const map = mapRef.current;
return () => {
for (const objectUrl of map.keys()) {
URL.revokeObjectURL(objectUrl);
}
map.clear();
};
}, []);
return { createObjectUrl, resolveObjectUrls };
};
@@ -0,0 +1,61 @@
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useModals, VariantType } from '@gouvfr-lasuite/cunningham-react';
import { ALLOWED_IMAGE_MIME_TYPES } from '@/features/blocknote/image-block';
import { AttachmentHelper } from '@/features/utils/attachment-helper';
/**
* Hook that returns an `uploadFile` function compatible with BlockNote's
* `useCreateBlockNote({ uploadFile })`. Images are read as base64 data URLs
* and stored directly in the block content (no blob upload).
*
* Used by TemplateComposer and SignatureComposer where content is persisted
* as self-contained HTML/JSON (no attachment system).
*
* Returns `null` when the file is rejected (wrong type, too large, read error)
* so that the caller can handle block cleanup.
*/
export const useUploadImageAsBase64 = (maxImageSize: number) => {
const { t, i18n } = useTranslation();
const modals = useModals();
const uploadFile = useCallback(
(file: File): Promise<string | null> => {
if (!ALLOWED_IMAGE_MIME_TYPES.includes(file.type)) {
return Promise.resolve(null);
}
if (file.size > maxImageSize) {
modals.messageModal({
title: (
<span className="c__modal__text--centered">
{t('Image size limit exceeded')}
</span>
),
children: (
<span className="c__modal__text--centered">
{t('Cannot add image. File size exceeds the {{maxSize}} limit.', {
maxSize: AttachmentHelper.getFormattedSize(
maxImageSize,
i18n.resolvedLanguage,
),
})}
</span>
),
messageType: VariantType.INFO,
});
return Promise.resolve(null);
}
return new Promise<string | null>((resolve) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => resolve(null);
reader.readAsDataURL(file);
});
},
[maxImageSize, modals, t, i18n.resolvedLanguage],
);
return uploadFile;
};
@@ -1,11 +1,11 @@
import { BlockSchema, InlineContentSchema, StyleSchema } from "@blocknote/core";
import { useBlockNoteEditor, useComponentsContext, useEditorState } from "@blocknote/react";
import { useTranslation } from "react-i18next";
import { Icon, IconSize } from "@gouvfr-lasuite/ui-kit";
import { MessageComposerBlockSchema, MessageComposerInlineContentSchema, MessageComposerStyleSchema } from "@/features/forms/components/message-composer";
export const ImageUploadButton = () => {
const { t } = useTranslation();
const editor = useBlockNoteEditor<MessageComposerBlockSchema, MessageComposerInlineContentSchema, MessageComposerStyleSchema>();
const editor = useBlockNoteEditor<BlockSchema, InlineContentSchema, StyleSchema>();
const Components = useComponentsContext()!;
const hasInlineContent = useEditorState({
@@ -1,16 +1,18 @@
span[data-inline-content-type="template-variable"] {
padding: var(--c--globals--spacings--4xs) var(--c--globals--spacings--2xs);
border-radius: 4px;
background: var(--c--contextuals--background--semantic--brand--secondary);
color: var(--c--contextuals--content--semantic--brand--primary);
font-size: var(--c--globals--font--sizes--xs);
border: 1px solid var(--c--contextuals--border--semantic--brand--secondary);
user-select: none;
font-family: monospace;
// Those styles should be applied only in template and signature composers
.template-composer, .signature-composer {
span[data-inline-content-type="template-variable"] {
padding: var(--c--globals--spacings--4xs) var(--c--globals--spacings--2xs);
border-radius: 4px;
background: var(--c--contextuals--background--semantic--brand--secondary);
color: var(--c--contextuals--content--semantic--brand--primary);
font-size: var(--c--globals--font--sizes--xs);
border: 1px solid var(--c--contextuals--border--semantic--brand--secondary);
user-select: none;
font-family: monospace;
}
.node-template-variable.ProseMirror-selectednode span[data-inline-content-type="template-variable"] {
background: #94badc;
}
}
.node-template-variable.ProseMirror-selectednode span[data-inline-content-type="template-variable"] {
background: #94badc;
}
@@ -1,11 +1,37 @@
import { createReactInlineContentSpec } from "@blocknote/react";
import React, { useMemo } from "react";
import { useBlockNoteEditor, useComponentsContext } from "@blocknote/react";
import { BlockSchema, StyleSchema, defaultInlineContentSpecs, InlineContentSchemaFromSpecs } from "@blocknote/core";
import { Icon, IconSize, Spinner } from "@gouvfr-lasuite/ui-kit";
import { PlaceholdersRetrieve200 } from "@/features/api/gen";
import { SignatureComposerBlockSchema, SignatureComposerInlineContentSchema, SignatureComposerStyleSchema } from "@/features/signatures/components/signature-composer";
import { useTranslation } from "react-i18next";
export const InlineTemplateVariable = createReactInlineContentSpec(
{
type: "template-variable",
content: "none",
propSchema: {
value: { default: "" },
label: { default: "" },
},
},
{
render: ({ inlineContent: { props } }) => {
return (
// TODO : Find a way to display variable name
// and (de)serialize this inline content during export and parsing
<span data-inline-type="template-variable">
{`{${props.value}}`}
</span>
);
},
}
);
type TemplateVariableInlineContentSchema = InlineContentSchemaFromSpecs<
typeof defaultInlineContentSpecs & { 'template-variable': typeof InlineTemplateVariable }
>;
type TemplateVariableSelectorProps = {
variables: PlaceholdersRetrieve200;
isLoading: boolean;
@@ -13,7 +39,7 @@ type TemplateVariableSelectorProps = {
export const TemplateVariableSelector = ({ variables, isLoading }: TemplateVariableSelectorProps) => {
const { t } = useTranslation();
const editor = useBlockNoteEditor<SignatureComposerBlockSchema, SignatureComposerInlineContentSchema, SignatureComposerStyleSchema>();
const editor = useBlockNoteEditor<BlockSchema, TemplateVariableInlineContentSchema, StyleSchema>();
const Components = useComponentsContext()!;
const variableItems = useMemo(() => {
if (!variables) return [];
@@ -58,30 +84,3 @@ export const TemplateVariableSelector = ({ variables, isLoading }: TemplateVaria
/>
);
}
export const InlineTemplateVariable = createReactInlineContentSpec(
{
type: "template-variable",
content: "none",
propSchema: {
value: { default: "" },
label: { default: "" },
},
},
{
render: ({ inlineContent: { props } }) => {
return (
// TODO : Find a way to display variable name
// and (de)serialize this inline content during export and parsing
<span data-inline-type="template-variable">
{`{${props.value}}`}
</span>
);
},
}
);
@@ -2,21 +2,25 @@ import { useBlockNoteEditor, useComponentsContext, useEditorState } from "@block
import { useTranslation } from "react-i18next";
import { Icon, IconSize, Spinner } from "@gouvfr-lasuite/ui-kit";
import { Modal, ModalSize } from "@gouvfr-lasuite/cunningham-react";
import { MessageTemplateTypeChoices, ReadOnlyMessageTemplate, useMailboxesMessageTemplatesAvailableList, mailboxesMessageTemplatesRenderRetrieve, MailboxesMessageTemplatesRenderRetrieveParams } from "@/features/api/gen";
import { MessageTemplateTypeChoices, ReadOnlyMessageTemplate, useMailboxesMessageTemplatesAvailableList, draftPlaceholdersRetrieve, DraftPlaceholdersRetrieve200 } from "@/features/api/gen";
import { MessageComposerBlockSchema, MessageComposerInlineContentSchema, MessageComposerStyleSchema, PartialMessageComposerBlockSchema } from "@/features/forms/components/message-composer";
import { useModal } from "@gouvfr-lasuite/cunningham-react";
import { handle } from "@/features/utils/errors";
import MailHelper from "@/features/utils/mail-helper";
import { resolveTemplateVariables } from "@/features/blocknote/utils";
type MessageTemplateSelectorProps = {
mailboxId: string;
context?: Record<string, string>;
messageId?: string;
ensureDraft?: () => Promise<string | undefined>;
uploadInlineImage?: (file: File) => Promise<{ url: string; blobId: string } | null>;
}
/**
* A BlockNote toolbar selector which allows the user to select a message template
* from all active templates for a given mailbox.
*/
export const MessageTemplateSelector = ({ mailboxId, context = {} }: MessageTemplateSelectorProps) => {
export const MessageTemplateSelector = ({ mailboxId, messageId, ensureDraft, uploadInlineImage }: MessageTemplateSelectorProps) => {
const { t } = useTranslation();
const editor = useBlockNoteEditor<MessageComposerBlockSchema, MessageComposerInlineContentSchema, MessageComposerStyleSchema>();
const Components = useComponentsContext()!;
@@ -45,24 +49,58 @@ export const MessageTemplateSelector = ({ mailboxId, context = {} }: MessageTemp
const handleSelect = async (template: ReadOnlyMessageTemplate) => {
if (!template.raw_body || !template.id) return;
try {
// Get rendered template content (allows to use placeholders)
const { data: renderedTemplate } = await mailboxesMessageTemplatesRenderRetrieve(
mailboxId,
template.id,
context as MailboxesMessageTemplatesRenderRetrieveParams,
);
if (!renderedTemplate?.html_body) {
handle(new Error("Failed to render template."), { extra: { templateId: template.id, mailboxId: mailboxId } });
return;
}
const resolvedMessageId = messageId ?? await ensureDraft?.();
if (!resolvedMessageId) return;
// Parse template blocks for signature
try {
// Resolve placeholder values from the draft context
const { data: resolvedPlaceholders } = await draftPlaceholdersRetrieve(
resolvedMessageId,
) as { data: DraftPlaceholdersRetrieve200 };
// Parse raw blocks and resolve template variables client-side
const blocks = JSON.parse(template.raw_body);
const templateSignature = blocks.find((block: { type: string }) => block.type === "signature");
const templateBlocks = blocks.filter((block: { type: string }) => block.type !== "signature");
const contentBlocks = resolveTemplateVariables(templateBlocks, resolvedPlaceholders) as PartialMessageComposerBlockSchema[];
// Convert HTML to blocks using BlockNote's built-in parser
const contentBlocks = await editor.tryParseHTMLToBlocks(renderedTemplate.html_body) as PartialMessageComposerBlockSchema[];
// Convert base64 images to blobs via upload
if (uploadInlineImage) {
const blocksToRemove = new Set<number>();
await Promise.all(
contentBlocks.map(async (block, index) => {
if (block.type !== 'image' || !block.props?.url?.startsWith('data:')) return;
const file = MailHelper.dataUrlToFile(block.props.url, `template-image-${index}.png`);
if (!file) {
blocksToRemove.add(index);
return;
}
try {
const result = await uploadInlineImage(file);
if (result) {
contentBlocks[index] = {
...block,
props: { ...block.props, url: result.url },
} as PartialMessageComposerBlockSchema;
} else {
blocksToRemove.add(index);
}
} catch (error) {
handle(
new Error("Failed to upload inline image."),
{ extra: { error, block, index } }
);
blocksToRemove.add(index);
return;
}
})
);
// Remove failed blocks (reverse order to preserve indices)
for (const index of Array.from(blocksToRemove).sort((a, b) => b - a)) {
contentBlocks.splice(index, 1);
}
}
// Check if there's already a signature in the editor
const editorSignature = editor.getBlock("signature");
@@ -73,7 +111,8 @@ export const MessageTemplateSelector = ({ mailboxId, context = {} }: MessageTemp
...templateSignature,
props: {
...templateSignature.props,
mailboxId
mailboxId,
messageId: resolvedMessageId,
}
} as PartialMessageComposerBlockSchema);
}
@@ -22,7 +22,7 @@ export const QuotedMessageBlock = createReactBlockSpec(
const { t, i18n } = useTranslation();
return (
<div data-content-type="quote">
<div data-content-type="quote" style={{ userSelect: 'none' }}>
<blockquote>
<p>{props.mode === "reply" ? t('In reply to') : t('Forwarded message')}</p>
<p><strong>{t('From:')}</strong> {props.sender}</p>
@@ -1,16 +1,19 @@
import { createReactBlockSpec, useBlockNoteEditor, useComponentsContext, useEditorSelectionChange, useEditorChange, useEditorState } from "@blocknote/react";
import { Icon, IconSize, Spinner } from "@gouvfr-lasuite/ui-kit";
import { useState } from "react";
import { useMemo, useState } from "react";
import { Props } from "@blocknote/core";
import DomPurify from "dompurify";
import { ReadOnlyMessageTemplate, useMailboxesMessageTemplatesRenderRetrieve } from "@/features/api/gen";
import { ReadOnlyMessageTemplate, useMailboxesMessageTemplatesRetrieve, useDraftPlaceholdersRetrieve, DraftPlaceholdersRetrieve200 } from "@/features/api/gen";
import { MessageComposerBlockSchema, MessageComposerInlineContentSchema, MessageComposerStyleSchema, PartialMessageComposerBlockSchema } from "@/features/forms/components/message-composer";
import { useTranslation } from "react-i18next";
import { MessageComposerHelper } from "@/features/utils/composer-helper";
import { useHtmlWithObjectUrls } from "@/features/blocknote/image-block/use-html-with-object-urls";
type SignatureTemplateSelectorProps = {
mailboxId?: string;
messageId?: string;
ensureDraft?: () => Promise<string | undefined>;
templates?: ReadOnlyMessageTemplate[];
defaultSelected?: string | null;
isLoading?: boolean;
@@ -20,7 +23,7 @@ type SignatureTemplateSelectorProps = {
* A BlockNote toolbar selector which allows the user to select a signature template from
* all active signatures for a given mailbox.
*/
export const SignatureTemplateSelector = ({ mailboxId, templates = [], defaultSelected, isLoading }: SignatureTemplateSelectorProps) => {
export const SignatureTemplateSelector = ({ mailboxId, messageId, ensureDraft, templates = [], defaultSelected, isLoading }: SignatureTemplateSelectorProps) => {
const editor = useBlockNoteEditor<MessageComposerBlockSchema, MessageComposerInlineContentSchema, MessageComposerStyleSchema>();
const { t } = useTranslation();
const Components = useComponentsContext()!;
@@ -35,7 +38,6 @@ export const SignatureTemplateSelector = ({ mailboxId, templates = [], defaultSe
},
});
// Tracks whether the text & background are both blue.
const [isSelected, setIsSelected] = useState<string | null>(defaultSelected ?? null);
const forcedTemplate = templates.find(template => template.is_forced);
const isForced = !!forcedTemplate;
@@ -86,7 +88,7 @@ export const SignatureTemplateSelector = ({ mailboxId, templates = [], defaultSe
return (
<Components.FormattingToolbar.Select
key={"templateVariableSelector"}
key="signatureTemplateSelector"
items={[
{
text: t("No signature"),
@@ -102,7 +104,7 @@ export const SignatureTemplateSelector = ({ mailboxId, templates = [], defaultSe
isSelected: isSelected === template.id,
isDisabled: template.is_forced,
icon: <Icon name={template.is_forced ? "lock" : "drive_file_rename_outline"} size={IconSize.SMALL} />,
onClick: () => {
onClick: async () => {
const signatureBlock = editor.getBlock('signature');
// If this signature is already selected, check if it can be deselected
@@ -119,13 +121,16 @@ export const SignatureTemplateSelector = ({ mailboxId, templates = [], defaultSe
return;
}
const resolvedMessageId = messageId ?? await ensureDraft?.();
// Otherwise, add or replace the signature
const newBlock = {
id: "signature",
type: "signature" as const,
props: {
templateId: template.id,
mailboxId: mailboxId
mailboxId: mailboxId,
messageId: resolvedMessageId,
}
};
@@ -166,33 +171,51 @@ export const BlockSignature = createReactBlockSpec(
propSchema: {
templateId: { default: "" },
mailboxId: { default: "" },
username: { default: "" },
messageId: { default: "" },
}
},
{
render: ({ block : { props }}) => {
const enabled = !!props.mailboxId && !!props.templateId;
// eslint-disable-next-line react-hooks/rules-of-hooks
const { data: { data: preview = null } = {}, isLoading } = useMailboxesMessageTemplatesRenderRetrieve(
const { data: { data: template = null } = {}, isFetching: isLoadingTemplate } = useMailboxesMessageTemplatesRetrieve(
props.mailboxId,
props.templateId,
{},
{
query: {
enabled: !!props.mailboxId && !!props.templateId,
}
}
{ query: { enabled } },
);
// eslint-disable-next-line react-hooks/rules-of-hooks
const { data: { data: placeholders = {} } = {}, isFetching: isLoadingPlaceholders } = useDraftPlaceholdersRetrieve(
props.messageId,
{ query: { enabled: enabled && !!props.messageId } },
);
const isLoading = isLoadingTemplate || isLoadingPlaceholders;
// eslint-disable-next-line react-hooks/rules-of-hooks
const sanitizedHtml = useMemo(() => {
if (isLoading || !template?.html_body) return null;
let html = template.html_body;
for (const [key, value] of Object.entries(placeholders as DraftPlaceholdersRetrieve200)) {
html = html.replaceAll(`{${key}}`, value);
}
return DomPurify().sanitize(html);
}, [template?.html_body, placeholders, isLoading]);
// eslint-disable-next-line react-hooks/rules-of-hooks
const html = useHtmlWithObjectUrls(sanitizedHtml);
if (isLoading) {
return <Spinner size="sm" />;
}
if (!preview?.html_body) {
if (!html) {
return null;
}
return (
<div dangerouslySetInnerHTML={{ __html: DomPurify().sanitize(preview.html_body) }} />
<div style={{ userSelect: 'none', width: '100%' }} dangerouslySetInnerHTML={{ __html: html }} />
)
},
toExternalHTML: () => (<span />),
@@ -7,6 +7,7 @@ import {
FilePreviewButton,
FileReplaceButton,
FormattingToolbar,
TextAlignButton,
} from "@blocknote/react";
type ToolbarProps = {
@@ -36,6 +37,9 @@ export const Toolbar = ({ children }: ToolbarProps) => {
basicTextStyle={"strike"}
key={"strikeStyleButton"}
/>
<TextAlignButton textAlignment={"left"} key={"textAlignLeftButton"} />
<TextAlignButton textAlignment={"center"} key={"textAlignCenterButton"} />
<TextAlignButton textAlignment={"right"} key={"textAlignRightButton"} />
<CreateLinkButton key={"createLinkButton"} />
{children}
</FormattingToolbar>
@@ -0,0 +1,76 @@
import * as locales from '@blocknote/core/locales';
import { Block } from '@blocknote/core';
import { TFunction } from 'i18next';
import { ALLOWED_IMAGE_MIME_TYPES } from '@/features/blocknote/image-block';
/**
* Builds the BlockNote i18n dictionary for the given locale.
*/
export const createBlockNoteDictionary = (locale: string, t: TFunction) => ({
...(locales[locale as keyof typeof locales] || locales.en),
placeholders: {
...(locales[locale as keyof typeof locales] || locales.en).placeholders,
emptyDocument: t('Start typing...'),
default: t('Start typing...'),
},
});
/**
* Returns TipTap handleDOMEvents handlers that block non-image file
* drops and pastes. Used by composers that only accept image uploads
* (SignatureComposer, TemplateComposer).
*/
export const createNonImageFileBlockers = () => ({
drop: (_view: unknown, event: DragEvent) => {
const files = Array.from(event.dataTransfer?.files || []);
if (files.length === 0) return false;
const hasNonImage = files.some(f => !ALLOWED_IMAGE_MIME_TYPES.includes(f.type));
if (hasNonImage) {
event.preventDefault();
return true;
}
return false;
},
paste: (_view: unknown, event: ClipboardEvent) => {
const files = Array.from(event.clipboardData?.files || []);
if (files.length === 0) return false;
const hasNonImage = files.some(f => !ALLOWED_IMAGE_MIME_TYPES.includes(f.type));
if (hasNonImage) {
event.preventDefault();
return true;
}
return false;
},
});
/**
* Replaces `template-variable` inline content nodes with plain text
* using resolved placeholder values. Recurses into children blocks.
*/
export const resolveTemplateVariables = (
blocks: Block[],
resolvedValues: Record<string, string>,
): Block[] => {
return blocks.map((block) => {
const resolvedBlock = { ...block };
if (Array.isArray(block.content)) {
resolvedBlock.content = block.content.flatMap(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(ic: any) => {
if (ic.type === 'template-variable') {
const value = resolvedValues[ic.props?.value] ?? `{${ic.props?.value}}`;
return { type: 'text' as const, text: value, styles: {} };
}
return ic;
},
);
}
if (Array.isArray(block.children) && block.children.length > 0) {
resolvedBlock.children = resolveTemplateVariables(block.children, resolvedValues);
}
return resolvedBlock;
});
};
@@ -1,11 +1,10 @@
"use client";
import * as locales from '@blocknote/core/locales';
import { useCreateBlockNote } from "@blocknote/react";
import { useTranslation } from "react-i18next";
import { BlockNoteEditor, BlockNoteEditorOptions, BlockNoteSchema, defaultBlockSpecs, PartialBlock } from '@blocknote/core';
import { MessageTemplateSelector } from '@/features/blocknote/message-template-block';
import { imageBlockSpec, ALLOWED_IMAGE_MIME_TYPES } from '@/features/blocknote/image-block';
import MailHelper from '@/features/utils/mail-helper';
import { EmailExporter } from '@/features/blocknote/email-exporter';
import { FieldProps } from '@gouvfr-lasuite/cunningham-react';
import { useFormContext } from 'react-hook-form';
import { useEffect, useRef } from 'react';
@@ -19,6 +18,7 @@ import { MessageTemplateTypeChoices, useMailboxesMessageTemplatesAvailableList }
import { Attachment } from '@/features/api/gen/models/attachment';
import { MessageComposerHelper } from '@/features/utils/composer-helper';
import { SmartTrailingBlock } from '@/features/blocknote/smart-trailing-block';
import { createBlockNoteDictionary } from '@/features/blocknote/utils';
import { MessageFormValues } from '../message-form';
import { DriveFile } from '../message-form/drive-attachment-picker';
@@ -41,6 +41,8 @@ export type MessageComposerInlineContentSchema = MessageComposerBlockNoteSchema[
export type MessageComposerStyleSchema = MessageComposerBlockNoteSchema['styleSchema'];
export type PartialMessageComposerBlockSchema = PartialBlock<MessageComposerBlockSchema, MessageComposerInlineContentSchema, MessageComposerStyleSchema>;
const emailExporter = new EmailExporter();
export type QuoteType = "reply" | "forward";
type MessageComposerProps = FieldProps & {
@@ -50,6 +52,7 @@ type MessageComposerProps = FieldProps & {
disabled?: boolean;
draft?: Message;
submitDraft?: () => void;
ensureDraft?: () => Promise<string | undefined>;
quotedMessage?: Message;
quoteType?: QuoteType;
uploadInlineImage: (file: File) => Promise<{ url: string; blobId: string } | null>;
@@ -69,7 +72,7 @@ type MessageComposerProps = FieldProps & {
* to retrieve all the content of the message.
*/
export const MessageComposer = ({ mailboxId, blockNoteOptions, defaultValue, quotedMessage, quoteType, disabled = false, draft, submitDraft, uploadInlineImage, uploadFiles, removeInlineImage, attachments, ...props }: MessageComposerProps) => {
export const MessageComposer = ({ mailboxId, blockNoteOptions, defaultValue, quotedMessage, quoteType, disabled = false, draft, submitDraft, ensureDraft, uploadInlineImage, uploadFiles, removeInlineImage, attachments, ...props }: MessageComposerProps) => {
const form = useFormContext<MessageFormValues>();
const { t, i18n } = useTranslation();
const { data: { data: activeSignatures = [] } = {}, isLoading: isLoadingSignatures } = useMailboxesMessageTemplatesAvailableList(
@@ -107,9 +110,17 @@ export const MessageComposer = ({ mailboxId, blockNoteOptions, defaultValue, quo
const uploadFilesRef = useRef(uploadFiles);
uploadFilesRef.current = uploadFiles;
const uploadFile = async (file: File) => {
const editorRef = useRef<BlockNoteEditor<MessageComposerBlockSchema, MessageComposerInlineContentSchema, MessageComposerStyleSchema>>(null);
const uploadFile = async (file: File, blockId?: string) => {
const attachment = await uploadInlineImageRef.current(file);
return attachment?.url || "#";
if (!attachment) {
if (blockId) {
setTimeout(() => editorRef.current?.removeBlocks([blockId]), 0);
}
return '';
}
return attachment.url;
};
// Intercept non-image file drops/pastes before BlockNote processes them.
@@ -166,14 +177,7 @@ export const MessageComposer = ({ mailboxId, blockNoteOptions, defaultValue, quo
trailingBlock: false,
initialContent: getInitialContent(),
uploadFile,
dictionary: {
...(locales[locale as keyof typeof locales] || locales.en),
placeholders: {
...(locales[locale as keyof typeof locales] || locales.en).placeholders,
emptyDocument: t('Start typing...'),
default: t('Start typing...'),
}
},
dictionary: createBlockNoteDictionary(locale, t),
...blockNoteOptions,
_tiptapOptions: {
extensions: [SmartTrailingBlock],
@@ -243,6 +247,8 @@ export const MessageComposer = ({ mailboxId, blockNoteOptions, defaultValue, quo
},
}, [locale]);
editorRef.current = editor;
/**
* Register one-time load listeners on image blocks whose <img> is still
* loading. Once ALL pending images have loaded, handleChange is re-triggered
@@ -273,19 +279,10 @@ export const MessageComposer = ({ mailboxId, blockNoteOptions, defaultValue, quo
};
const handleChange = async (editor: BlockNoteEditor<MessageComposerBlockSchema, MessageComposerInlineContentSchema, MessageComposerStyleSchema>, submitNeeded: boolean = true) => {
// Remove image blocks whose upload failed (url is "#")
const failedImageBlocks = editor.document.filter(
(block) => block.type === 'image' && block.props.url === "#",
);
if (failedImageBlocks.length > 0) {
editor.removeBlocks(failedImageBlocks.map((b) => b.id));
return;
}
registerImageLoadListeners(editor);
const blocks = editor.document;
const markdown = await editor.blocksToMarkdownLossy(blocks);
const html = await MailHelper.markdownToHtml(markdown);
const html = emailExporter.exportBlocks(blocks, editor.domElement ?? null, { wrapInSection: true });
form.setValue("messageDraftBody", JSON.stringify(editor.document), { shouldDirty: true });
form.setValue("messageTextBody", markdown);
form.setValue("messageHtmlBody", html);
@@ -321,6 +318,7 @@ export const MessageComposer = ({ mailboxId, blockNoteOptions, defaultValue, quo
* Process the html and text content of the message when the editor is mounted.
*/
useEffect(() => {
editorRef.current = editor;
if (!editor) return;
handleChange(editor, false);
}, [editor])
@@ -380,6 +378,7 @@ export const MessageComposer = ({ mailboxId, blockNoteOptions, defaultValue, quo
props: {
templateId: signatureToUse.id,
mailboxId: mailboxId,
messageId: draft?.id,
}
};
@@ -399,6 +398,21 @@ export const MessageComposer = ({ mailboxId, blockNoteOptions, defaultValue, quo
}
}, [editor, isLoadingSignatures, activeSignatures, draft?.signature?.id]);
// When a draft is created after the signature block was inserted,
// update the block's messageId so placeholders can be resolved.
useEffect(() => {
if (!editor || !draft?.id) return;
const signatureBlock = editor.getBlock('signature');
if (signatureBlock) {
const blockProps = signatureBlock.props as BlockSignatureConfigProps;
if (blockProps.messageId !== draft.id) {
editor.updateBlock('signature', {
props: { messageId: draft.id }
});
}
}
}, [editor, draft?.id]);
// Sync direction: attachments → editor.
// Removes image blocks whose attachment was deleted externally (e.g. via AttachmentUploader).
// The reverse direction (editor → attachments) lives in handleChange above.
@@ -436,16 +450,16 @@ export const MessageComposer = ({ mailboxId, blockNoteOptions, defaultValue, quo
<ImageUploadButton />
<MessageTemplateSelector
mailboxId={mailboxId}
context={{
recipient_name: draft
? draft.to.map(to => to.contact.name).join(", ")
: quotedMessage?.sender?.name || ""
}}
messageId={draft?.id}
ensureDraft={ensureDraft}
uploadInlineImage={uploadInlineImage}
/>
<SignatureTemplateSelector
templates={activeSignatures}
isLoading={isLoadingSignatures}
mailboxId={mailboxId}
messageId={draft?.id}
ensureDraft={ensureDraft}
defaultSelected={draft?.signature?.id}
/>
</Toolbar>
@@ -400,12 +400,14 @@ export const MessageForm = ({
/**
* Update or create a draft message if any field to change.
* When `force` is true, bypass the dirty-fields check (used by ensureDraft).
* Returns the draft id on success.
*/
const saveDraft = async () => {
const saveDraftInner = async (force = false): Promise<string | undefined> => {
const data = form.getValues();
if (!canWriteMessages || isSavingDraft) return;
if (!canWriteMessages || isSavingDraft) return draft?.id;
const saveDraftNeeded = (
const saveDraftNeeded = force || (
Object.keys(form.formState.dirtyFields).length > 0
&& (
!!draft || (
@@ -422,7 +424,7 @@ export const MessageForm = ({
)
if (!saveDraftNeeded) {
return;
return draft?.id;
}
const payload = {
@@ -447,7 +449,7 @@ export const MessageForm = ({
});
} else if (form.formState.dirtyFields.from) {
await handleChangeSender(payload);
return;
return draft?.id;
} else {
response = await draftUpdateMutation.mutateAsync({
messageId: draft.id,
@@ -457,13 +459,26 @@ export const MessageForm = ({
const newDraft = response.data as Message;
setDraft(newDraft);
return newDraft.id;
} catch (error) {
console.warn("Error in saveDraft:", error);
return draft?.id;
} finally {
startAutoSave();
}
}
const saveDraft = () => saveDraftInner(false);
/**
* Ensure a draft exists, creating one if necessary.
* Returns the draft id.
*/
const ensureDraft = async (): Promise<string | undefined> => {
if (draft) return draft.id;
return saveDraftInner(true);
}
saveDraftRef.current = form.handleSubmit(saveDraft);
/**
@@ -645,6 +660,7 @@ export const MessageForm = ({
disabled={!canWriteMessages}
draft={draft}
submitDraft={form.handleSubmit(saveDraft)}
ensureDraft={ensureDraft}
blockNoteOptions={{ autofocus: canWriteMessages ? "end" : undefined }}
uploadInlineImage={attachmentHook.uploadInlineImage}
uploadFiles={attachmentHook.uploadFiles}
@@ -1,21 +1,22 @@
import { BlockNoteViewField } from "@/features/blocknote/blocknote-view-field";
import { BlockNoteEditorOptions, BlockNoteSchema, defaultBlockSpecs, defaultInlineContentSpecs } from "@blocknote/core";
import { InlineTemplateVariable, TemplateVariableSelector } from "@/features/blocknote/inline-template-variable";
import * as locales from '@blocknote/core/locales';
import { useCreateBlockNote } from "@blocknote/react";
import { FieldProps } from "@gouvfr-lasuite/cunningham-react";
import { useEffect, useCallback } from "react";
import { useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { useEffect } from "react";
import { Toolbar } from "@/features/blocknote/toolbar";
import MailHelper from "@/features/utils/mail-helper";
import { BlockSignature, BlockSignatureConfigProps, SignatureTemplateSelector } from "@/features/blocknote/signature-block";
import { MessageTemplateTypeChoices, useMailboxesMessageTemplatesAvailableList, usePlaceholdersRetrieve } from "@/features/api/gen";
import { useMailboxContext } from "@/features/providers/mailbox";
import { imageBlockSpec } from "@/features/blocknote/image-block";
import { ImageUploadButton } from "@/features/blocknote/image-upload-button";
import { SmartTrailingBlock } from "@/features/blocknote/smart-trailing-block";
import { useBase64Composer } from "@/features/blocknote/hooks/use-base64-composer";
import { BodyHiddenInputs } from "@/features/blocknote/body-hidden-inputs";
const TEMPLATE_BLOCKNOTE_SCHEMA = BlockNoteSchema.create({
blockSpecs: {
...defaultBlockSpecs,
'image': imageBlockSpec,
'signature': BlockSignature(),
},
inlineContentSpecs: {
@@ -39,10 +40,16 @@ type TemplateComposerProps = FieldProps & {
* The composer component for the template content.
*/
export const TemplateComposer = ({ blockNoteOptions, defaultValue, disabled = false, ...props }: TemplateComposerProps) => {
const { t, i18n } = useTranslation();
const form = useFormContext();
const { selectedMailbox } = useMailboxContext();
const { editor, handleChange } = useBase64Composer({
schema: TEMPLATE_BLOCKNOTE_SCHEMA,
defaultValue,
blockNoteOptions,
trailingBlock: false,
extensions: [SmartTrailingBlock],
});
const { data: { data: placeholders = {} } = {}, isLoading: isLoadingPlaceholders } = usePlaceholdersRetrieve({
query: {
refetchOnMount: true,
@@ -64,43 +71,15 @@ export const TemplateComposer = ({ blockNoteOptions, defaultValue, disabled = fa
}
);
const locale = i18n.resolvedLanguage?.split('-')[0] || 'en';
const editor = useCreateBlockNote({
schema: TEMPLATE_BLOCKNOTE_SCHEMA,
tabBehavior: "prefer-navigate-ui",
initialContent: defaultValue ? JSON.parse(defaultValue): [{ type: "paragraph", content: [{ type: "text", text: "", styles: {} }] }],
trailingBlock: false,
dictionary: {
...(locales[locale as keyof typeof locales] || locales.en),
placeholders: {
...(locales[locale as keyof typeof locales] || locales.en).placeholders,
emptyDocument: t('Start typing...'),
default: t('Start typing...'),
}
},
...blockNoteOptions,
}, [i18n.resolvedLanguage]);
const handleChange = useCallback(async () => {
const markdown = await editor.blocksToMarkdownLossy(editor.document);
const html = await MailHelper.markdownToHtml(markdown);
form.setValue("rawBody", JSON.stringify(editor.document), { shouldDirty: true });
form.setValue("textBody", markdown);
form.setValue("htmlBody", html);
// No need to update signatureId in form as it's only used for UI
}, [editor, form]);
// Detect current signature on mount and update it, then sync form values
useEffect(() => {
if(!editor) return;
// Detect current signature on mount
const signatureBlock = editor.getBlock('signature');
if (signatureBlock?.type === 'signature') {
const templateId = signatureBlock.props.templateId;
const signature = activeSignatures.find(s => s.id === templateId);
if (signature) {
// Update the signature selector
editor.updateBlock(signatureBlock.id, {
type: 'signature',
props: {
@@ -110,18 +89,14 @@ export const TemplateComposer = ({ blockNoteOptions, defaultValue, disabled = fa
});
}
}
}, [editor, activeSignatures, selectedMailbox?.id]);
handleChange();
}, [editor, handleChange, activeSignatures, selectedMailbox?.id]);
// Insert or remove forced signature block
useEffect(() => {
if (!editor || isLoadingSignatures) return;
// Check if signature is already in the editor
const signatureBlock = editor.getBlock('signature');
if (signatureBlock) {
// In case there is a signature block, we remove the block if :
// - the templateId does not match an active signature
const blockSignatureId = (signatureBlock.props as BlockSignatureConfigProps).templateId;
const isSignatureStale = activeSignatures.findIndex(signature => signature.id === blockSignatureId) < 0;
if (isSignatureStale) editor.removeBlocks(["signature"]);
@@ -130,15 +105,9 @@ export const TemplateComposer = ({ blockNoteOptions, defaultValue, disabled = fa
if (activeSignatures.length === 0) return;
let signatureToUse = undefined;
// Use in priority the forced signature block if it exists
signatureToUse = activeSignatures.find(signature => signature.is_forced);
// Add signature block if we have a signature to use
const signatureToUse = activeSignatures.find(signature => signature.is_forced);
if (signatureToUse) {
// Add signature at the end of the document
const signatureBlock = {
const newSignatureBlock = {
id: "signature",
type: "signature" as const,
props: {
@@ -147,15 +116,11 @@ export const TemplateComposer = ({ blockNoteOptions, defaultValue, disabled = fa
}
};
// Insert at the end
if (editor.document.length === 0) {
editor.insertBlocks([{ type: "paragraph", content: [{ type: "text", text: "", styles: {} }] }], "", "after");
}
// Put signature at the end of the document
// Insert signature at the end of the document
editor.insertBlocks([signatureBlock], editor.document[editor.document.length - 1].id, "after");
editor.insertBlocks([newSignatureBlock], editor.document[editor.document.length - 1].id, "after");
}
}, [editor, isLoadingSignatures, activeSignatures, selectedMailbox?.id]);
@@ -172,6 +137,7 @@ export const TemplateComposer = ({ blockNoteOptions, defaultValue, disabled = fa
}}
>
<Toolbar>
<ImageUploadButton />
<SignatureTemplateSelector
templates={activeSignatures}
isLoading={isLoadingSignatures}
@@ -183,9 +149,7 @@ export const TemplateComposer = ({ blockNoteOptions, defaultValue, disabled = fa
/>
</Toolbar>
</BlockNoteViewField>
<input {...form.register("htmlBody")} type="hidden" />
<input {...form.register("textBody")} type="hidden" />
<input {...form.register("rawBody")} type="hidden" />
<BodyHiddenInputs />
</>
)
);
};
@@ -25,6 +25,7 @@ const DEFAULT_CONFIG: AppConfig = {
MAX_OUTGOING_BODY_SIZE: 0,
MAX_INCOMING_EMAIL_SIZE: 0,
MAX_RECIPIENTS_PER_MESSAGE: 0,
MAX_TEMPLATE_IMAGE_SIZE: 0,
IMAGE_PROXY_ENABLED: false,
DRIVE: DEFAULT_DRIVE_CONFIG,
MESSAGES_MANUAL_RETRY_MAX_AGE: 0,
@@ -1,19 +1,22 @@
import { BlockNoteViewField } from "@/features/blocknote/blocknote-view-field";
import { BlockNoteEditor, BlockNoteEditorOptions, BlockNoteSchema, defaultInlineContentSpecs, PartialBlock } from "@blocknote/core";
import { BlockNoteEditor, BlockNoteEditorOptions, BlockNoteSchema, defaultBlockSpecs, defaultInlineContentSpecs, PartialBlock } from "@blocknote/core";
import { filterSuggestionItems } from "@blocknote/core/extensions";
import * as locales from '@blocknote/core/locales';
import { SuggestionMenuController, useCreateBlockNote } from "@blocknote/react";
import { SuggestionMenuController } from "@blocknote/react";
import { FieldProps } from "@gouvfr-lasuite/cunningham-react";
import { useEffect } from "react";
import { useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { InlineTemplateVariable, TemplateVariableSelector } from "@/features/blocknote/inline-template-variable";
import { Toolbar } from "@/features/blocknote/toolbar";
import { usePlaceholdersRetrieve } from "@/features/api/gen";
import MailHelper from "@/features/utils/mail-helper";
import { imageBlockSpec } from "@/features/blocknote/image-block";
import { ImageUploadButton } from "@/features/blocknote/image-upload-button";
import { useBase64Composer } from "@/features/blocknote/hooks/use-base64-composer";
import { BodyHiddenInputs } from "@/features/blocknote/body-hidden-inputs";
const SIGNATURE_BLOCKNOTE_SCHEMA = BlockNoteSchema.create({
blockSpecs: {
...defaultBlockSpecs,
'image': imageBlockSpec,
},
inlineContentSpecs: {
...defaultInlineContentSpecs,
'template-variable': InlineTemplateVariable,
@@ -37,50 +40,23 @@ type SignatureComposerProps = FieldProps & {
* Used by both admin (maildomain) and mailbox signature modals.
*/
export const SignatureComposer = ({ blockNoteOptions, defaultValue, disabled = false, ...props }: SignatureComposerProps) => {
const { t, i18n } = useTranslation();
const form = useFormContext();
const { data: { data: placeholders = {} } = {}, isLoading: isLoadingPlaceholders } = usePlaceholdersRetrieve();
const canShowPlaceholdersMenu = !isLoadingPlaceholders && !!placeholders;
const locale = i18n.resolvedLanguage?.split('-')[0] || 'en';
const editor = useCreateBlockNote({
const { editor, handleChange } = useBase64Composer({
schema: SIGNATURE_BLOCKNOTE_SCHEMA,
tabBehavior: "prefer-navigate-ui",
autofocus: "end",
initialContent: defaultValue ? JSON.parse(defaultValue): [{ type: "paragraph", content: "" }],
trailingBlock: false,
dictionary: {
...(locales[locale as keyof typeof locales] || locales.en),
placeholders: {
...(locales[locale as keyof typeof locales] || locales.en).placeholders,
emptyDocument: t('Start typing...'),
default: t('Start typing...'),
}
},
...blockNoteOptions,
}, [i18n.resolvedLanguage]);
defaultValue,
blockNoteOptions: { autofocus: "end", ...blockNoteOptions },
});
const handleChange = async () => {
const markdown = await editor.blocksToMarkdownLossy(editor.document);
const html = await MailHelper.markdownToHtml(markdown);
form.setValue("rawBody", JSON.stringify(editor.document), { shouldDirty: true });
form.setValue("textBody", markdown);
form.setValue("htmlBody", html);
}
const { data: { data: placeholders = {} } = {}, isLoading: isLoadingPlaceholders } = usePlaceholdersRetrieve();
const canShowPlaceholdersMenu = !isLoadingPlaceholders && !!Object.keys(placeholders).length;
const getPlaceholderMenuItems = (editor: BlockNoteEditor<SignatureComposerBlockSchema, SignatureComposerInlineContentSchema, SignatureComposerStyleSchema>) => {
return Object.entries(placeholders).map(([value, label]) => ({
title: label,
onItemClick: () => {
editor.insertInlineContent([{ type: "template-variable", props: { value: value, label: label } }, " "]);
editor.insertInlineContent([{ type: "template-variable", props: { value, label } }, " "]);
}
}));
}
useEffect(() => {
handleChange();
}, [])
};
return (
<>
@@ -95,8 +71,9 @@ export const SignatureComposer = ({ blockNoteOptions, defaultValue, disabled = f
}}
>
<Toolbar>
<ImageUploadButton />
{canShowPlaceholdersMenu &&
<TemplateVariableSelector key={"templateVariableSelector"} variables={placeholders} isLoading={isLoadingPlaceholders} />
<TemplateVariableSelector key="templateVariableSelector" variables={placeholders} isLoading={isLoadingPlaceholders} />
}
</Toolbar>
{canShowPlaceholdersMenu &&
@@ -106,10 +83,7 @@ export const SignatureComposer = ({ blockNoteOptions, defaultValue, disabled = f
/>
}
</BlockNoteViewField>
<input {...form.register("htmlBody")} type="hidden" />
<input {...form.register("textBody")} type="hidden" />
<input {...form.register("rawBody")} type="hidden" />
<BodyHiddenInputs />
</>
)
);
};
@@ -813,6 +813,56 @@ describe('MailHelper', () => {
});
});
describe('dataUrlToFile', () => {
it('should convert a valid PNG data URL to a File', () => {
// 1x1 red PNG as base64
const base64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==';
const dataUrl = `data:image/png;base64,${base64}`;
const file = MailHelper.dataUrlToFile(dataUrl, 'test.png');
expect(file).not.toBeNull();
expect(file!.name).toBe('test.png');
expect(file!.type).toBe('image/png');
expect(file!.size).toBeGreaterThan(0);
});
it('should convert a valid JPEG data URL to a File', () => {
// Minimal valid JPEG (SOI + APP0 + EOI markers)
const base64 = '/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAABAAEDASIAAhEBAxEB/8QAFAABAAAAAAAAAAAAAAAAAAAACf/EABQQAQAAAAAAAAAAAAAAAAAAAAD/xAAUAQEAAAAAAAAAAAAAAAAAAAAA/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEQMRAD8AKwA//9k=';
const dataUrl = `data:image/jpeg;base64,${base64}`;
const file = MailHelper.dataUrlToFile(dataUrl, 'photo.jpg');
expect(file).not.toBeNull();
expect(file!.name).toBe('photo.jpg');
expect(file!.type).toBe('image/jpeg');
});
it('should return null for a non-data URL', () => {
const file = MailHelper.dataUrlToFile('https://example.com/image.png', 'test.png');
expect(file).toBeNull();
});
it('should return null for a non-image data URL', () => {
const file = MailHelper.dataUrlToFile('data:text/plain;base64,SGVsbG8=', 'test.txt');
expect(file).toBeNull();
});
it('should return null for a malformed data URL', () => {
const file = MailHelper.dataUrlToFile('data:image/png;base64', 'test.png');
expect(file).toBeNull();
});
it('should return null for invalid base64 content', () => {
const file = MailHelper.dataUrlToFile('data:image/png;base64,!!!invalid!!!', 'test.png');
expect(file).toBeNull();
});
it('should return null for empty string', () => {
const file = MailHelper.dataUrlToFile('', 'test.png');
expect(file).toBeNull();
});
});
describe('DetectionMap', () => {
it('should not have invalid regex patterns', () => {
// A test guard to ensure that the detection map does not contain malformed regex patterns
@@ -243,6 +243,27 @@ class MailHelper {
return [text.replace(regex, '').trim(), driveAttachments];
}
/**
* Convert a data URL (base64-encoded) to a File object.
* Returns null if the input is not a valid image data URL.
*/
static dataUrlToFile(dataUrl: string, filename: string): File | null {
const match = dataUrl.match(/^data:(image\/[\w+.-]+);base64,(.+)$/);
if (!match) return null;
const [, mimeType, base64Data] = match;
try {
const binaryString = atob(base64Data);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return new File([bytes], filename, { type: mimeType });
} catch {
return null;
}
}
/**
* Extract drive attachments from html body.
*/
+1
View File
@@ -3,6 +3,7 @@
:root {
--header-height: 52px;
--c--components--forms-checkbox--size: 24px;
--toastify-z-index: 9999999;
}
@media screen and (max-width: breakpoint(tablet)) {