From c53e8bfcd480f3e6dbbcc5c0d4ec82b5fedff277 Mon Sep 17 00:00:00 2001 From: jbpenrath Date: Wed, 11 Feb 2026 13:44:40 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(global)=20allow=20to=20add=20image=20?= =?UTF-8?q?block=20into=20template=20and=20signature=20composers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/email-exporter.md | 216 +++++++ docs/env.md | 7 +- src/backend/core/api/openapi.json | 142 ++--- src/backend/core/api/serializers.py | 38 ++ src/backend/core/api/viewsets/config.py | 7 + .../core/api/viewsets/message_template.py | 60 +- src/backend/core/api/viewsets/placeholder.py | 68 ++ src/backend/core/mda/outbound.py | 105 ++- src/backend/core/mda/rfc5322/__init__.py | 4 + src/backend/core/mda/rfc5322/utils.py | 146 +++++ src/backend/core/models.py | 54 +- ...test_admin_maildomains_message_template.py | 69 ++ src/backend/core/tests/api/test_config.py | 2 + .../api/test_mailbox_message_template.py | 89 +++ .../tests/api/test_message_template_render.py | 318 --------- .../api/test_message_template_retrieve.py | 87 +++ .../tests/api/test_placeholder_resolve.py | 281 ++++++++ src/backend/core/tests/mda/test_outbound.py | 178 ++++++ .../core/tests/mda/test_rfc5322_utils.py | 263 ++++++++ .../tests/models/test_message_template.py | 216 +++++++ .../core/tests/models/test_messages.py | 1 - src/backend/core/urls.py | 7 +- .../locale/en_US/LC_MESSAGES/django.po | 14 +- .../locale/fr_FR/LC_MESSAGES/django.mo | Bin 17807 -> 17955 bytes .../locale/fr_FR/LC_MESSAGES/django.po | 15 +- src/backend/messages/settings.py | 6 + src/frontend/public/locales/common/en-US.json | 3 +- src/frontend/public/locales/common/fr-FR.json | 3 +- .../features/api/gen/mailboxes/mailboxes.ts | 251 +------- .../src/features/api/gen/messages/messages.ts | 203 ++++++ .../api/gen/models/config_retrieve200.ts | 2 + ...0.ts => draft_placeholders_retrieve200.ts} | 8 +- .../src/features/api/gen/models/index.ts | 3 +- ...essage_templates_render_retrieve_params.ts | 14 - .../features/blocknote/body-hidden-inputs.tsx | 16 + .../blocknote/email-exporter/index.test.tsx | 602 ++++++++++++++++++ .../blocknote/email-exporter/index.tsx | 378 +++++++++++ .../blocknote/hooks/use-base64-composer.tsx | 122 ++++ .../features/blocknote/image-block/index.ts | 42 -- .../image-block/use-html-with-object-urls.ts | 57 ++ .../image-block/use-image-object-urls.ts | 48 ++ .../use-upload-image-as-base64.tsx | 61 ++ .../blocknote/image-upload-button/index.tsx | 4 +- .../inline-template-variable/_index.scss | 30 +- .../inline-template-variable/index.tsx | 57 +- .../message-template-block/index.tsx | 75 ++- .../blocknote/quoted-message-block/index.tsx | 2 +- .../blocknote/signature-block/index.tsx | 57 +- .../src/features/blocknote/toolbar.tsx | 4 + src/frontend/src/features/blocknote/utils.ts | 76 +++ .../components/message-composer/index.tsx | 70 +- .../forms/components/message-form/index.tsx | 26 +- .../template-composer.tsx | 84 +-- .../src/features/providers/config.tsx | 1 + .../components/signature-composer/index.tsx | 70 +- .../src/features/utils/mail-helper.test.tsx | 50 ++ .../src/features/utils/mail-helper.tsx | 21 + src/frontend/src/styles/globals.scss | 1 + 58 files changed, 3809 insertions(+), 1025 deletions(-) create mode 100644 docs/email-exporter.md create mode 100644 src/backend/core/mda/rfc5322/utils.py delete mode 100644 src/backend/core/tests/api/test_message_template_render.py create mode 100644 src/backend/core/tests/api/test_placeholder_resolve.py create mode 100644 src/backend/core/tests/mda/test_rfc5322_utils.py create mode 100644 src/backend/core/tests/models/test_message_template.py delete mode 100644 src/backend/core/tests/models/test_messages.py rename src/frontend/src/features/api/gen/models/{mailboxes_message_templates_render_retrieve200.ts => draft_placeholders_retrieve200.ts} (53%) delete mode 100644 src/frontend/src/features/api/gen/models/mailboxes_message_templates_render_retrieve_params.ts create mode 100644 src/frontend/src/features/blocknote/body-hidden-inputs.tsx create mode 100644 src/frontend/src/features/blocknote/email-exporter/index.test.tsx create mode 100644 src/frontend/src/features/blocknote/email-exporter/index.tsx create mode 100644 src/frontend/src/features/blocknote/hooks/use-base64-composer.tsx create mode 100644 src/frontend/src/features/blocknote/image-block/use-html-with-object-urls.ts create mode 100644 src/frontend/src/features/blocknote/image-block/use-image-object-urls.ts create mode 100644 src/frontend/src/features/blocknote/image-block/use-upload-image-as-base64.tsx create mode 100644 src/frontend/src/features/blocknote/utils.ts diff --git a/docs/email-exporter.md b/docs/email-exporter.md new file mode 100644 index 00000000..453b504b --- /dev/null +++ b/docs/email-exporter.md @@ -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 `` +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` | `

` (via ``) | textAlignment, textColor, backgroundColor | +| `heading` | `

`–`

` (via ``) | level, textAlignment, textColor, backgroundColor | +| `quote` | `
` with left border | textAlignment, textColor, backgroundColor | +| `codeBlock` | `
` | Grey background, rounded corners |
+| `divider` | `
` (via `
`) | — | + +### List blocks + +Consecutive list items of the same type are grouped into a single `
'); + }); + + 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
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 = ( + {caption + ); + + 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)) {