mirror of
https://github.com/suitenumerique/messages.git
synced 2026-09-27 04:04:54 +02:00
✨(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:
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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  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 ``
|
||||
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
@@ -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><b>Alice & Co.</b></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"] == ""
|
||||
@@ -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""
|
||||
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\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"" 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\n"
|
||||
f"Middle\n\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 = ""
|
||||
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\n"
|
||||
"\nBye"
|
||||
)
|
||||
result, images = extract_base64_images_from_text(text)
|
||||
|
||||
assert len(images) == 1
|
||||
assert "data:image" not in result
|
||||
assert "" 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""
|
||||
_, 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""
|
||||
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"" 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""
|
||||
_, 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"\n"
|
||||
f""
|
||||
)
|
||||
result, images = extract_base64_images_from_text(
|
||||
text, known_images=known_images
|
||||
)
|
||||
|
||||
assert len(images) == 1
|
||||
cid = images[0]["cid"]
|
||||
assert f"" in result
|
||||
assert f"" 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><b>Alice & Co.</b></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.
|
||||
@@ -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(),
|
||||
|
||||
@@ -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.
@@ -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 à"
|
||||
|
||||
|
||||
@@ -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 */
|
||||
|
||||
+4
-4
@@ -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";
|
||||
|
||||
-14
@@ -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("hello")');
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 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}
|
||||
|
||||
+24
-60
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user