', () => {
+ const html = exportBlocks([codeBlock('console.log("hello")')]);
+ expect(html).toContain('');
+ expect(html).toContain('console.log("hello")');
+ });
+ });
+
+ // -----------------------------------------------------------------------
+ // 9. Quote
+ // -----------------------------------------------------------------------
+ describe('quote', () => {
+ it('renders with border-left', () => {
+ const html = exportBlocks([quote('A wise thought')]);
+ expect(html).toContain(' {
+ it('renders with margin:12px 0', () => {
+ const html = exportBlocks([divider()]);
+ expect(html).toContain(' {
+ it('does not render table block', () => {
+ const html = exportBlocks([
+ block('table'),
+ ]);
+ expect(html).not.toContain('');
+ });
+
+ it('renders signature as empty ', () => {
+ const html = exportBlocks([block('signature')]);
+ expect(html).toContain('', () => {
+ const html = exportBlocks([block('quoted-message')]);
+ expect(html).toContain('', () => {
+ const html = exportBlocks([
+ block('custom-block', 'Some content'),
+ ]);
+ expect(html).toContain('');
+ 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('
');
+ 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(`"
Hello world
"`);
+ });
+
+ it('renders a heading with block-level color', () => {
+ const html = exportBlocks([
+ heading('Important', 2, { textColor: 'red' }),
+ ]);
+ expect(html).toMatchInlineSnapshot(`"
Important "`);
+ });
+
+ 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(`"
A nice photo "`);
+ });
+ });
+});
diff --git a/src/frontend/src/features/blocknote/email-exporter/index.tsx b/src/frontend/src/features/blocknote/email-exporter/index.tsx
new file mode 100644
index 00000000..fa026234
--- /dev/null
+++ b/src/frontend/src/features/blocknote/email-exporter/index.tsx
@@ -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
;
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+type AnyInlineContent = InlineContent;
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+type AnyStyledText = StyledText;
+
+// Inline copy of COLORS_DEFAULT from @blocknote/core (not part of the public API)
+const COLORS: Record = {
+ 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): 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): 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 {st.text} ;
+}
+
+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.content.map((st, j) => renderStyledText(st, j))}
+
+ );
+ }
+ if (ic.type === 'template-variable') {
+ const variable = ic as unknown as { props: Record };
+ return {`{${variable.props.value}}`} ;
+ }
+ 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;
+ const style = blockPropsToCSS(props);
+ const content = block.content as AnyInlineContent[] | undefined;
+
+ if (block.type === 'checkListItem') {
+ const checked = (props.checked as boolean) || false;
+ return (
+
+ {/* Apply a negative margin to the checkbox to position it in the marker area (mimic list-style-position: outside) */}
+
+ {renderInlineContent(content || [])}
+ {nestedContent}
+
+ );
+ }
+
+ return (
+
+ {renderInlineContent(content || [])}
+ {nestedContent}
+
+ );
+}
+
+function renderBlock(
+ block: AnyBlock,
+ editorDomElement: HTMLElement | null,
+ key: number,
+): React.ReactNode {
+ const props = block.props as Record;
+ const style = blockPropsToCSS(props);
+ const content = block.content as AnyInlineContent[] | undefined;
+
+ switch (block.type) {
+ case 'paragraph': {
+ if (isContentEmpty(content)) {
+ return ;
+ }
+ return (
+
+ {renderInlineContent(content!)}
+
+ );
+ }
+
+ 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 (
+
+ {renderInlineContent(content || [])}
+
+ );
+ }
+
+ 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(
+ `[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 = (
+
+ );
+
+ if (caption) {
+ return (
+
+ {imgNode}
+ {caption}
+
+ );
+ }
+ return React.cloneElement(imgNode, { key });
+ }
+
+ case 'codeBlock': {
+ return (
+
+ {renderInlineContent(content || [])}
+
+ );
+ }
+
+ case 'quote': {
+ return (
+
+ {renderInlineContent(content || [])}
+
+ );
+ }
+
+ case 'divider': {
+ return ;
+ }
+
+ case 'signature':
+ case 'quoted-message':
+ return ;
+
+ default:
+ if (content && content.length > 0) {
+ return {renderInlineContent(content)}
;
+ }
+ 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({listItems} );
+ } 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}>);
+ }
+}
diff --git a/src/frontend/src/features/blocknote/hooks/use-base64-composer.tsx b/src/frontend/src/features/blocknote/hooks/use-base64-composer.tsx
new file mode 100644
index 00000000..19114c58
--- /dev/null
+++ b/src/frontend/src/features/blocknote/hooks/use-base64-composer.tsx
@@ -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, InlineContentSchemaFromSpecs, StyleSchemaFromSpecs>;
+ defaultValue?: string | null;
+ blockNoteOptions?: Partial, InlineContentSchemaFromSpecs, StyleSchemaFromSpecs>>;
+ 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) => {
+ 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, InlineContentSchemaFromSpecs, StyleSchemaFromSpecs>>(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, i: number) => {
+ const props = block.props as Record | 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, InlineContentSchemaFromSpecs, StyleSchemaFromSpecs>[],
+ 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 };
+};
diff --git a/src/frontend/src/features/blocknote/image-block/index.ts b/src/frontend/src/features/blocknote/image-block/index.ts
index 1843ff54..9fe4c4a9 100644
--- a/src/frontend/src/features/blocknote/image-block/index.ts
+++ b/src/frontend/src/features/blocknote/image-block/index.ts
@@ -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 ( or ).
- 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
- // 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(
- `[data-id="${block.id}"] img`,
- );
- if (imgEl?.complete && imgEl.naturalWidth > 0) {
- exportedImg.width = imgEl.naturalWidth;
- }
- }
-
- return result;
- },
},
};
diff --git a/src/frontend/src/features/blocknote/image-block/use-html-with-object-urls.ts b/src/frontend/src/features/blocknote/image-block/use-html-with-object-urls.ts
new file mode 100644
index 00000000..10602d89
--- /dev/null
+++ b/src/frontend/src/features/blocknote/image-block/use-html-with-object-urls.ts
@@ -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([]);
+
+ 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;
+};
diff --git a/src/frontend/src/features/blocknote/image-block/use-image-object-urls.ts b/src/frontend/src/features/blocknote/image-block/use-image-object-urls.ts
new file mode 100644
index 00000000..f7b8f01b
--- /dev/null
+++ b/src/frontend/src/features/blocknote/image-block/use-image-object-urls.ts
@@ -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>(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 };
+};
diff --git a/src/frontend/src/features/blocknote/image-block/use-upload-image-as-base64.tsx b/src/frontend/src/features/blocknote/image-block/use-upload-image-as-base64.tsx
new file mode 100644
index 00000000..bbd836ae
--- /dev/null
+++ b/src/frontend/src/features/blocknote/image-block/use-upload-image-as-base64.tsx
@@ -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 => {
+ if (!ALLOWED_IMAGE_MIME_TYPES.includes(file.type)) {
+ return Promise.resolve(null);
+ }
+
+ if (file.size > maxImageSize) {
+ modals.messageModal({
+ title: (
+
+ {t('Image size limit exceeded')}
+
+ ),
+ children: (
+
+ {t('Cannot add image. File size exceeds the {{maxSize}} limit.', {
+ maxSize: AttachmentHelper.getFormattedSize(
+ maxImageSize,
+ i18n.resolvedLanguage,
+ ),
+ })}
+
+ ),
+ messageType: VariantType.INFO,
+ });
+ return Promise.resolve(null);
+ }
+
+ return new Promise((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;
+};
diff --git a/src/frontend/src/features/blocknote/image-upload-button/index.tsx b/src/frontend/src/features/blocknote/image-upload-button/index.tsx
index b39f3078..8d87b64f 100644
--- a/src/frontend/src/features/blocknote/image-upload-button/index.tsx
+++ b/src/frontend/src/features/blocknote/image-upload-button/index.tsx
@@ -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();
+ const editor = useBlockNoteEditor();
const Components = useComponentsContext()!;
const hasInlineContent = useEditorState({
diff --git a/src/frontend/src/features/blocknote/inline-template-variable/_index.scss b/src/frontend/src/features/blocknote/inline-template-variable/_index.scss
index 0ef609cf..3f9d05a5 100644
--- a/src/frontend/src/features/blocknote/inline-template-variable/_index.scss
+++ b/src/frontend/src/features/blocknote/inline-template-variable/_index.scss
@@ -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;
-}
-
diff --git a/src/frontend/src/features/blocknote/inline-template-variable/index.tsx b/src/frontend/src/features/blocknote/inline-template-variable/index.tsx
index cf1867ba..53962322 100644
--- a/src/frontend/src/features/blocknote/inline-template-variable/index.tsx
+++ b/src/frontend/src/features/blocknote/inline-template-variable/index.tsx
@@ -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
+
+ {`{${props.value}}`}
+
+ );
+ },
+ }
+);
+
+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();
+ const editor = useBlockNoteEditor();
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
-
- {`{${props.value}}`}
-
- );
- },
- }
-);
-
-
-
-
diff --git a/src/frontend/src/features/blocknote/message-template-block/index.tsx b/src/frontend/src/features/blocknote/message-template-block/index.tsx
index 63a4f094..bf02577d 100644
--- a/src/frontend/src/features/blocknote/message-template-block/index.tsx
+++ b/src/frontend/src/features/blocknote/message-template-block/index.tsx
@@ -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;
+ messageId?: string;
+ ensureDraft?: () => Promise;
+ 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();
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();
+ 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);
}
diff --git a/src/frontend/src/features/blocknote/quoted-message-block/index.tsx b/src/frontend/src/features/blocknote/quoted-message-block/index.tsx
index 2a2d0e74..6a9c12cf 100644
--- a/src/frontend/src/features/blocknote/quoted-message-block/index.tsx
+++ b/src/frontend/src/features/blocknote/quoted-message-block/index.tsx
@@ -22,7 +22,7 @@ export const QuotedMessageBlock = createReactBlockSpec(
const { t, i18n } = useTranslation();
return (
-
+
{props.mode === "reply" ? t('In reply to') : t('Forwarded message')}
{t('From:')} {props.sender}
diff --git a/src/frontend/src/features/blocknote/signature-block/index.tsx b/src/frontend/src/features/blocknote/signature-block/index.tsx
index 8b416607..cee7852e 100644
--- a/src/frontend/src/features/blocknote/signature-block/index.tsx
+++ b/src/frontend/src/features/blocknote/signature-block/index.tsx
@@ -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;
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();
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(defaultSelected ?? null);
const forcedTemplate = templates.find(template => template.is_forced);
const isForced = !!forcedTemplate;
@@ -86,7 +88,7 @@ export const SignatureTemplateSelector = ({ mailboxId, templates = [], defaultSe
return (
,
- 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 ;
}
- if (!preview?.html_body) {
+ if (!html) {
return null;
}
return (
-
+
)
},
toExternalHTML: () => ( ),
diff --git a/src/frontend/src/features/blocknote/toolbar.tsx b/src/frontend/src/features/blocknote/toolbar.tsx
index cfbe07e5..60813376 100644
--- a/src/frontend/src/features/blocknote/toolbar.tsx
+++ b/src/frontend/src/features/blocknote/toolbar.tsx
@@ -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"}
/>
+
+
+
{children}
diff --git a/src/frontend/src/features/blocknote/utils.ts b/src/frontend/src/features/blocknote/utils.ts
new file mode 100644
index 00000000..4b864796
--- /dev/null
+++ b/src/frontend/src/features/blocknote/utils.ts
@@ -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,
+): 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;
+ });
+};
diff --git a/src/frontend/src/features/forms/components/message-composer/index.tsx b/src/frontend/src/features/forms/components/message-composer/index.tsx
index 34eaa71b..66db9e74 100644
--- a/src/frontend/src/features/forms/components/message-composer/index.tsx
+++ b/src/frontend/src/features/forms/components/message-composer/index.tsx
@@ -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;
+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;
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();
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>(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 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, 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
to.contact.name).join(", ")
- : quotedMessage?.sender?.name || ""
- }}
+ messageId={draft?.id}
+ ensureDraft={ensureDraft}
+ uploadInlineImage={uploadInlineImage}
/>
diff --git a/src/frontend/src/features/forms/components/message-form/index.tsx b/src/frontend/src/features/forms/components/message-form/index.tsx
index 5098948d..578274d5 100644
--- a/src/frontend/src/features/forms/components/message-form/index.tsx
+++ b/src/frontend/src/features/forms/components/message-form/index.tsx
@@ -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 => {
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 => {
+ 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}
diff --git a/src/frontend/src/features/layouts/components/mailbox-settings/modal-compose-template/template-composer.tsx b/src/frontend/src/features/layouts/components/mailbox-settings/modal-compose-template/template-composer.tsx
index bac34a77..9d6955ac 100644
--- a/src/frontend/src/features/layouts/components/mailbox-settings/modal-compose-template/template-composer.tsx
+++ b/src/frontend/src/features/layouts/components/mailbox-settings/modal-compose-template/template-composer.tsx
@@ -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
}}
>
+
-
-
-
+
>
- )
+ );
};
diff --git a/src/frontend/src/features/providers/config.tsx b/src/frontend/src/features/providers/config.tsx
index 768df0fd..242d2af3 100644
--- a/src/frontend/src/features/providers/config.tsx
+++ b/src/frontend/src/features/providers/config.tsx
@@ -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,
diff --git a/src/frontend/src/features/signatures/components/signature-composer/index.tsx b/src/frontend/src/features/signatures/components/signature-composer/index.tsx
index 1d7623d3..23f4167c 100644
--- a/src/frontend/src/features/signatures/components/signature-composer/index.tsx
+++ b/src/frontend/src/features/signatures/components/signature-composer/index.tsx
@@ -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) => {
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
}}
>
+
{canShowPlaceholdersMenu &&
-
+
}
{canShowPlaceholdersMenu &&
@@ -106,10 +83,7 @@ export const SignatureComposer = ({ blockNoteOptions, defaultValue, disabled = f
/>
}
-
-
-
+
>
- )
+ );
};
-
diff --git a/src/frontend/src/features/utils/mail-helper.test.tsx b/src/frontend/src/features/utils/mail-helper.test.tsx
index 9c5062ea..7cbc8362 100644
--- a/src/frontend/src/features/utils/mail-helper.test.tsx
+++ b/src/frontend/src/features/utils/mail-helper.test.tsx
@@ -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
diff --git a/src/frontend/src/features/utils/mail-helper.tsx b/src/frontend/src/features/utils/mail-helper.tsx
index db0043aa..f5b5ed10 100644
--- a/src/frontend/src/features/utils/mail-helper.tsx
+++ b/src/frontend/src/features/utils/mail-helper.tsx
@@ -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.
*/
diff --git a/src/frontend/src/styles/globals.scss b/src/frontend/src/styles/globals.scss
index 5a9f23c3..3d492db2 100644
--- a/src/frontend/src/styles/globals.scss
+++ b/src/frontend/src/styles/globals.scss
@@ -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)) {