mirror of
https://github.com/suitenumerique/messages.git
synced 2026-08-17 21:25:41 +02:00
🐛(frontend) only use blocknote allowed colors
Currently when a user paste content into the composer, if this one has text or background color, this is preserved as is then export into the output. Now only color supported by blocknote are preserved and exported. We also apply this sanitization to table elements. Furthermore, we also drop unsupported blocks (file, audio, video) and fix a bug that prevent to embed external images.
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Inline copy of COLORS_DEFAULT from @blocknote/core (not part of the public API).
|
||||
*
|
||||
* This is both the palette the editor UI can apply and the only one its
|
||||
* stylesheet renders: BlockNote styles named selectors (`[data-text-color=blue]`),
|
||||
* so any other value is stored but never displayed.
|
||||
*/
|
||||
export const BLOCKNOTE_COLORS: Record<string, { text: string; background: string }> = {
|
||||
gray: { text: '#9b9a97', background: '#ebeced' },
|
||||
brown: { text: '#64473a', background: '#e9e5e3' },
|
||||
red: { text: '#e03e3e', background: '#fbe4e4' },
|
||||
orange: { text: '#d9730d', background: '#f6e9d9' },
|
||||
yellow: { text: '#dfab01', background: '#fbf3db' },
|
||||
green: { text: '#4d6461', background: '#ddedea' },
|
||||
blue: { text: '#0b6e99', background: '#ddebf1' },
|
||||
purple: { text: '#6940a5', background: '#eae4f2' },
|
||||
pink: { text: '#ad1a72', background: '#f4dfeb' },
|
||||
};
|
||||
|
||||
/**
|
||||
* True when a `textColor` / `backgroundColor` value is one the editor can
|
||||
* actually render — a palette name or the `default` sentinel.
|
||||
*
|
||||
* Anything else comes from pasted foreign HTML: BlockNote maps a raw
|
||||
* `style="color: …"` onto these props whatever the value.
|
||||
*/
|
||||
export const isBlockNoteColor = (value: unknown): boolean =>
|
||||
typeof value === 'string' && (value === 'default' || value in BLOCKNOTE_COLORS);
|
||||
|
||||
/**
|
||||
* Resolves a `textColor` / `backgroundColor` value to its CSS color.
|
||||
*
|
||||
* @param value - the stored color value
|
||||
* @param variant - which side of the palette entry to read
|
||||
* @returns the CSS color, or `undefined` when the value is `default` or
|
||||
* outside the palette (and therefore must not reach the exported HTML)
|
||||
*/
|
||||
export const resolveBlockNoteColor = (
|
||||
value: unknown,
|
||||
variant: 'text' | 'background',
|
||||
): string | undefined => {
|
||||
if (typeof value !== 'string' || value === 'default') return undefined;
|
||||
return BLOCKNOTE_COLORS[value]?.[variant];
|
||||
};
|
||||
|
||||
/** Palette CSS value (lowercase 6-digit hex) → palette name, per variant. */
|
||||
const PALETTE_NAMES_BY_CSS_VALUE = {
|
||||
text: new Map(Object.entries(BLOCKNOTE_COLORS).map(([name, c]) => [c.text, name])),
|
||||
background: new Map(
|
||||
Object.entries(BLOCKNOTE_COLORS).map(([name, c]) => [c.background, name]),
|
||||
),
|
||||
};
|
||||
|
||||
const HEX_SHORTHAND = /^#([0-9a-f])([0-9a-f])([0-9a-f])$/;
|
||||
const HEX = /^#[0-9a-f]{6}$/;
|
||||
const RGB = /^rgba?\(\s*(\d+)[\s,]+(\d+)[\s,]+(\d+)\s*(?:[,/]\s*(\d*\.?\d+)\s*)?\)$/;
|
||||
|
||||
/**
|
||||
* Normalizes a CSS color to a lowercase 6-digit hex, so values written in
|
||||
* different notations can be compared to the palette.
|
||||
*
|
||||
* @returns the normalized hex, or `undefined` for a notation we do not compare
|
||||
* (named colors, `hsl()`, or any translucent color — which no palette entry is)
|
||||
*/
|
||||
const normalizeCssColor = (value: string): string | undefined => {
|
||||
const css = value.trim().toLowerCase();
|
||||
|
||||
const shorthand = HEX_SHORTHAND.exec(css);
|
||||
if (shorthand) {
|
||||
const [, r, g, b] = shorthand;
|
||||
return `#${r}${r}${g}${g}${b}${b}`;
|
||||
}
|
||||
if (HEX.test(css)) return css;
|
||||
|
||||
const rgb = RGB.exec(css);
|
||||
if (!rgb) return undefined;
|
||||
const [, r, g, b, alpha] = rgb;
|
||||
if (alpha !== undefined && Number(alpha) !== 1) return undefined;
|
||||
const channels = [r, g, b].map(Number);
|
||||
if (channels.some((channel) => channel > 255)) return undefined;
|
||||
return `#${channels.map((c) => c.toString(16).padStart(2, '0')).join('')}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Recognizes a raw CSS color as one of the palette entries.
|
||||
*
|
||||
* Our own exported mails carry palette colors as plain CSS (`color:#0b6e99`),
|
||||
* which the clipboard hands back as an off-palette value — so pasting a reply
|
||||
* onto a Messages mail would otherwise lose its colors. Matching is done per
|
||||
* variant: a text color is only recognized among the palette's text values.
|
||||
*
|
||||
* @returns the palette name, or `null` when the color is not one of ours
|
||||
*/
|
||||
export const matchBlockNoteColorName = (
|
||||
value: unknown,
|
||||
variant: 'text' | 'background',
|
||||
): string | null => {
|
||||
if (typeof value !== 'string') return null;
|
||||
const normalized = normalizeCssColor(value);
|
||||
if (!normalized) return null;
|
||||
return PALETTE_NAMES_BY_CSS_VALUE[variant].get(normalized) ?? null;
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import { vi } from 'vitest';
|
||||
import { BlockNoteEditor } from '@blocknote/core';
|
||||
import { EmailExporter } from './index';
|
||||
import {
|
||||
AnyBlock,
|
||||
@@ -203,11 +204,15 @@ describe('EmailExporter', () => {
|
||||
expect(html).toContain('text-decoration-line:underline line-through');
|
||||
});
|
||||
|
||||
it('passes through non-named color values', () => {
|
||||
it('drops color values outside the BlockNote palette', () => {
|
||||
// Such values only come from pasted foreign HTML and are invisible in the
|
||||
// editor, so exporting them would leak unapplied styling into the mail.
|
||||
const html = exportBlocks([
|
||||
paragraph([styledText('Custom', { textColor: '#ff00ff' })]),
|
||||
paragraph([styledText('Pasted', { textColor: 'rgb(51, 51, 51)' })], {
|
||||
backgroundColor: '#ffffff',
|
||||
}),
|
||||
]);
|
||||
expect(html).toContain('color:#ff00ff');
|
||||
expect(html).toBe('<p>Pasted</p>');
|
||||
});
|
||||
|
||||
it('ignores default color values', () => {
|
||||
@@ -434,6 +439,55 @@ describe('EmailExporter', () => {
|
||||
expect(html).toContain('Second');
|
||||
});
|
||||
|
||||
// A list pasted from a mail or a document can start anywhere; the editor
|
||||
// displays that number, so the mail must count from it too.
|
||||
it('carries the start of a numbered list onto the <ol>', () => {
|
||||
const html = exportBlocks([
|
||||
numberedListItem('Fifth', { start: 5 }),
|
||||
numberedListItem('Sixth'),
|
||||
]);
|
||||
expect(html).toContain('<ol start="5">');
|
||||
});
|
||||
|
||||
it('emits no start attribute for a list counting from 1', () => {
|
||||
const html = exportBlocks([
|
||||
numberedListItem('First', { start: 1 }),
|
||||
numberedListItem('Second'),
|
||||
]);
|
||||
expect(html).toContain('<ol>');
|
||||
});
|
||||
|
||||
it('ignores a start carried by a later item, like the editor does', () => {
|
||||
const html = exportBlocks([
|
||||
numberedListItem('First'),
|
||||
numberedListItem('Stray', { start: 9 }),
|
||||
]);
|
||||
expect(html).toContain('<ol>');
|
||||
expect(html).not.toContain('start=');
|
||||
});
|
||||
|
||||
it('ignores an unusable start value', () => {
|
||||
const html = exportBlocks([numberedListItem('First', { start: NaN })]);
|
||||
expect(html).toContain('<ol>');
|
||||
expect(html).not.toContain('start=');
|
||||
});
|
||||
|
||||
it('never puts a start on a bullet list', () => {
|
||||
const html = exportBlocks([bulletListItem('Item', { start: 5 })]);
|
||||
expect(html).toContain('<ul>');
|
||||
expect(html).not.toContain('start=');
|
||||
});
|
||||
|
||||
it('gives a nested numbered list its own start', () => {
|
||||
const html = exportBlocks([
|
||||
numberedListItem('Parent', {}, [
|
||||
numberedListItem('Nested third', { start: 3 }),
|
||||
numberedListItem('Nested fourth'),
|
||||
]),
|
||||
]);
|
||||
expect(html).toMatch(/<ol>[\s\S]*<ol start="3">[\s\S]*Nested third/);
|
||||
});
|
||||
|
||||
it('renders checked check list item with checked input', () => {
|
||||
const html = exportBlocks([
|
||||
checkListItem('Done', true),
|
||||
@@ -1027,3 +1081,20 @@ describe('EmailExporter', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Guards the whole chain for the case that motivated it: BlockNote only stores
|
||||
// `start` when it parses an `<ol start=…>` from pasted HTML.
|
||||
describe('EmailExporter on pasted HTML', () => {
|
||||
it('keeps the numbering of a pasted ordered list', async () => {
|
||||
const editor = BlockNoteEditor.create();
|
||||
const blocks = await editor.tryParseHTMLToBlocks(
|
||||
'<ol start="5"><li>cinq</li><li>six</li></ol>',
|
||||
);
|
||||
|
||||
const html = new EmailExporter().exportBlocks(blocks, null);
|
||||
|
||||
expect(html).toContain('<ol start="5">');
|
||||
expect(html).toContain('cinq');
|
||||
expect(html).toContain('six');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { VALID_LINK_PROTOCOLS } from '@blocknote/core';
|
||||
import type { Block, InlineContent, StyledText } from '@blocknote/core';
|
||||
import MailHelper from '@/features/utils/mail-helper';
|
||||
import { resolveBlockNoteColor } from '../colors';
|
||||
import { TEMPLATE_VARIABLE_TYPE } from '../inline-template-variable';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -12,19 +13,6 @@ type AnyInlineContent = InlineContent<any, any>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type AnyStyledText = StyledText<any>;
|
||||
|
||||
// Inline copy of COLORS_DEFAULT from @blocknote/core (not part of the public API)
|
||||
const COLORS: Record<string, { text: string; background: string }> = {
|
||||
gray: { text: '#9b9a97', background: '#ebeced' },
|
||||
brown: { text: '#64473a', background: '#e9e5e3' },
|
||||
red: { text: '#e03e3e', background: '#fbe4e4' },
|
||||
orange: { text: '#d9730d', background: '#f6e9d9' },
|
||||
yellow: { text: '#dfab01', background: '#fbf3db' },
|
||||
green: { text: '#4d6461', background: '#ddedea' },
|
||||
blue: { text: '#0b6e99', background: '#ddebf1' },
|
||||
purple: { text: '#6940a5', background: '#eae4f2' },
|
||||
pink: { text: '#ad1a72', background: '#f4dfeb' },
|
||||
};
|
||||
|
||||
// BlockNote renders heading sizes via CSS variables on `data-level` attributes,
|
||||
// not on the <h1>-<h6> tags — and its `.bn-default-styles` rule forces
|
||||
// `font-size: inherit` on those tags. A heading exported as a bare tag is thus
|
||||
@@ -82,16 +70,18 @@ function mapStyle(key: string, value: boolean | string): CSSProperties {
|
||||
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 {};
|
||||
// Off-palette colors are dropped, not passed through: they come from
|
||||
// pasted foreign HTML (BlockNote maps any `style="color: …"` onto these
|
||||
// props) and are invisible in the editor, so emitting them would leak
|
||||
// styling the user never applied into the sent HTML.
|
||||
case 'textColor': {
|
||||
const color = resolveBlockNoteColor(value, 'text');
|
||||
return color ? { color } : {};
|
||||
}
|
||||
case 'backgroundColor': {
|
||||
const backgroundColor = resolveBlockNoteColor(value, 'background');
|
||||
return backgroundColor ? { backgroundColor } : {};
|
||||
}
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
@@ -112,14 +102,14 @@ function blockPropsToCSS(props: Record<string, unknown>): CSSProperties {
|
||||
style.textAlign = alignment as CSSProperties['textAlign'];
|
||||
}
|
||||
|
||||
const textColor = props.textColor as string | undefined;
|
||||
if (textColor && textColor !== 'default') {
|
||||
style.color = COLORS[textColor]?.text || textColor;
|
||||
const textColor = resolveBlockNoteColor(props.textColor, 'text');
|
||||
if (textColor) {
|
||||
style.color = textColor;
|
||||
}
|
||||
|
||||
const bgColor = props.backgroundColor as string | undefined;
|
||||
if (bgColor && bgColor !== 'default') {
|
||||
style.backgroundColor = COLORS[bgColor]?.background || bgColor;
|
||||
const bgColor = resolveBlockNoteColor(props.backgroundColor, 'background');
|
||||
if (bgColor) {
|
||||
style.backgroundColor = bgColor;
|
||||
}
|
||||
|
||||
return style;
|
||||
@@ -203,10 +193,9 @@ function renderInlineContent(content: AnyInlineContent[]): React.ReactNode[] {
|
||||
}
|
||||
// Mirror the link text's own color onto the <a> so the underline
|
||||
// matches the text instead of staying the default link blue.
|
||||
const textColor = link.content
|
||||
.map((st) => st.styles?.textColor as string | undefined)
|
||||
.find((color) => color && color !== 'default');
|
||||
const linkColor = textColor && (COLORS[textColor]?.text || textColor);
|
||||
const linkColor = link.content
|
||||
.map((st) => resolveBlockNoteColor(st.styles?.textColor, 'text'))
|
||||
.find((color) => color !== undefined);
|
||||
return (
|
||||
<a
|
||||
key={i}
|
||||
@@ -302,6 +291,25 @@ function getListTag(blockType: string): ListTag | null {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the number an ordered list must start counting from.
|
||||
*
|
||||
* BlockNote stores it on the first item of a run only — a `start` on any later
|
||||
* item is ignored by its numbering plugin, exactly like the HTML attribute —
|
||||
* so the caller passes the item opening the run.
|
||||
*
|
||||
* @param block - the first list item of the run
|
||||
* @returns the start value, or `undefined` when the list counts from 1 (no
|
||||
* attribute to emit) or the stored value is not a usable number
|
||||
*/
|
||||
function listStart(block: AnyBlock): number | undefined {
|
||||
const start = (block.props as Record<string, unknown>).start;
|
||||
if (typeof start !== 'number' || !Number.isInteger(start) || start === 1) {
|
||||
return undefined;
|
||||
}
|
||||
return start;
|
||||
}
|
||||
|
||||
function renderListItem(
|
||||
block: AnyBlock,
|
||||
editorDomElement: HTMLElement | null,
|
||||
@@ -554,13 +562,13 @@ function tableCellStyle(props: Record<string, unknown> | undefined): CSSProperti
|
||||
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 textColor = resolveBlockNoteColor(props.textColor, 'text');
|
||||
if (textColor) {
|
||||
style.color = textColor;
|
||||
}
|
||||
const bgColor = props.backgroundColor as string | undefined;
|
||||
if (bgColor && bgColor !== 'default') {
|
||||
style.backgroundColor = COLORS[bgColor]?.background || bgColor;
|
||||
const bgColor = resolveBlockNoteColor(props.backgroundColor, 'background');
|
||||
if (bgColor) {
|
||||
style.backgroundColor = bgColor;
|
||||
}
|
||||
return style;
|
||||
}
|
||||
@@ -590,14 +598,14 @@ function renderTable(block: AnyBlock, key: number): React.ReactNode {
|
||||
const headerCols = tableContent.headerCols ?? 0;
|
||||
const columnWidths = tableContent.columnWidths || [];
|
||||
const blockProps = block.props as Record<string, unknown>;
|
||||
const blockTextColor = blockProps.textColor as string | undefined;
|
||||
const blockTextColor = resolveBlockNoteColor(blockProps.textColor, 'text');
|
||||
|
||||
const tableStyle: CSSProperties = {
|
||||
borderCollapse: 'collapse',
|
||||
wordBreak: 'break-word',
|
||||
};
|
||||
if (blockTextColor && blockTextColor !== 'default') {
|
||||
tableStyle.color = COLORS[blockTextColor]?.text || blockTextColor;
|
||||
if (blockTextColor) {
|
||||
tableStyle.color = blockTextColor;
|
||||
}
|
||||
|
||||
const colgroup = columnWidths.some((w) => typeof w === 'number') ? (
|
||||
@@ -677,7 +685,10 @@ function transformBlocks(
|
||||
}
|
||||
|
||||
const ListTag = listTag;
|
||||
result.push(<ListTag key={`list-${startI}`}>{listItems}</ListTag>);
|
||||
const start = listTag === 'ol' ? listStart(blocks[startI]) : undefined;
|
||||
result.push(
|
||||
<ListTag key={`list-${startI}`} start={start}>{listItems}</ListTag>,
|
||||
);
|
||||
} else {
|
||||
result.push(renderBlock(block, editorDomElement, i));
|
||||
|
||||
|
||||
@@ -11,7 +11,8 @@ import { EmailExporter } from '@/features/blocknote/email-exporter';
|
||||
import { blocksToMarkdown } from '@/features/blocknote/markdown-exporter';
|
||||
import { useConfig } from '@/features/providers/config';
|
||||
import MailHelper from '@/features/utils/mail-helper';
|
||||
import { backfillTemplateVariableContent, createBlockNoteDictionary, createNonImageFileBlockers } from '@/features/blocknote/utils';
|
||||
import { backfillTemplateVariableContent, createBlockNoteDictionary, createNonImageFileBlockers, dropUnsupportedBlocks } from '@/features/blocknote/utils';
|
||||
import { PasteColorSanitizer } from '@/features/blocknote/paste-sanitizer';
|
||||
import { handle } from '@/features/utils/errors';
|
||||
|
||||
const emailExporter = new EmailExporter();
|
||||
@@ -87,6 +88,11 @@ export const useBase64Composer = <
|
||||
// otherwise they render as empty blue chips.
|
||||
blocks = backfillTemplateVariableContent(blocks);
|
||||
|
||||
// Drop the blocks the schema no longer knows about, otherwise BlockNote
|
||||
// throws and the whole signature/template becomes impossible to open.
|
||||
blocks = dropUnsupportedBlocks(blocks, Object.keys(schema.blockSchema));
|
||||
if (blocks.length === 0) return DEFAULT_CONTENT;
|
||||
|
||||
// Traverse blocks tree to transform image data URLs to Object URLs
|
||||
let imageIndex = 0;
|
||||
const processImageBlocks = (blocks: Record<string, unknown>[]) => {
|
||||
@@ -110,7 +116,7 @@ export const useBase64Composer = <
|
||||
};
|
||||
|
||||
return processImageBlocks(blocks);
|
||||
}, [defaultValue, createObjectUrl]);
|
||||
}, [defaultValue, createObjectUrl, schema]);
|
||||
|
||||
const locale = i18n.resolvedLanguage?.split('-')[0] || 'en';
|
||||
const nonImageFileBlockers = createNonImageFileBlockers();
|
||||
@@ -124,7 +130,7 @@ export const useBase64Composer = <
|
||||
dictionary: createBlockNoteDictionary(locale, t),
|
||||
...blockNoteOptions,
|
||||
_tiptapOptions: {
|
||||
...(extensions ? { extensions } : {}),
|
||||
extensions: [...(extensions ?? []), PasteColorSanitizer],
|
||||
editorProps: {
|
||||
handleDOMEvents: nonImageFileBlockers,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
import { BlockNoteEditor } from '@blocknote/core';
|
||||
import { Node as PMNode, Schema } from '@tiptap/pm/model';
|
||||
import { EditorState } from '@tiptap/pm/state';
|
||||
import { EmailExporter } from './email-exporter';
|
||||
import { createPasteSanitizerPlugin, sanitizeDocumentColors } from './paste-sanitizer';
|
||||
|
||||
// Minimal schema mirroring how BlockNote stores colors: as node attributes for
|
||||
// block props, and as marks carrying a `stringValue` for inline styles.
|
||||
const schema = new Schema({
|
||||
nodes: {
|
||||
doc: { content: 'block+' },
|
||||
paragraph: {
|
||||
group: 'block',
|
||||
content: 'inline*',
|
||||
attrs: {
|
||||
textColor: { default: 'default' },
|
||||
backgroundColor: { default: 'default' },
|
||||
textAlignment: { default: 'left' },
|
||||
},
|
||||
},
|
||||
text: { group: 'inline' },
|
||||
},
|
||||
marks: {
|
||||
textColor: { attrs: { stringValue: { default: null } } },
|
||||
backgroundColor: { attrs: { stringValue: { default: null } } },
|
||||
bold: {},
|
||||
},
|
||||
});
|
||||
|
||||
const { paragraph } = schema.nodes;
|
||||
const marks = schema.marks;
|
||||
|
||||
const colorMark = (name: 'textColor' | 'backgroundColor', stringValue: string) =>
|
||||
marks[name].create({ stringValue });
|
||||
|
||||
const docOf = (...paragraphs: PMNode[]) => schema.nodes.doc.create(null, paragraphs);
|
||||
|
||||
const stateOf = (doc: PMNode) => EditorState.create({ doc });
|
||||
|
||||
const stateWithPlugin = (doc: PMNode) =>
|
||||
EditorState.create({ doc, plugins: [createPasteSanitizerPlugin()] });
|
||||
|
||||
// A table as a mail client or a spreadsheet puts it on the clipboard: colors
|
||||
// live on the cells, which BlockNote stores as `tableCell` / `tableHeader` props.
|
||||
const PASTED_TABLE_HTML =
|
||||
'<table><tr>' +
|
||||
'<th style="background-color:#4472c4;color:#ffffff">Nom</th>' +
|
||||
'</tr><tr>' +
|
||||
'<td style="background-color:#d9e2f3">Alice</td>' +
|
||||
'</tr></table>';
|
||||
|
||||
describe('recognizing palette colors written as raw CSS', () => {
|
||||
const nameOfMark = (doc: PMNode) =>
|
||||
doc.firstChild!.firstChild!.marks[0]?.attrs.stringValue ?? null;
|
||||
|
||||
const sanitizedMark = (value: string) => {
|
||||
const doc = docOf(
|
||||
paragraph.create(null, [schema.text('Text', [colorMark('textColor', value)])]),
|
||||
);
|
||||
const tr = sanitizeDocumentColors(stateOf(doc));
|
||||
return nameOfMark(tr ? tr.doc : doc);
|
||||
};
|
||||
|
||||
// BlockNote's blue text is #0b6e99 — the value our own exporter emits.
|
||||
it.each([
|
||||
['#0b6e99', 'lowercase hex'],
|
||||
['#0B6E99', 'uppercase hex'],
|
||||
['rgb(11, 110, 153)', 'rgb with spaces'],
|
||||
['rgb(11,110,153)', 'rgb without spaces'],
|
||||
[' #0b6e99 ', 'surrounding whitespace'],
|
||||
['rgba(11, 110, 153, 1)', 'rgba fully opaque'],
|
||||
])('names %s back to blue (%s)', (value) => {
|
||||
expect(sanitizedMark(value)).toBe('blue');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['rgb(11, 110, 154)', 'a near miss'],
|
||||
['rgba(11, 110, 153, 0.5)', 'a translucent palette color'],
|
||||
['hsl(202, 87%, 32%)', 'a notation we do not compare'],
|
||||
['#ddebf1', "blue's background value used as a text color"],
|
||||
])('still drops %s (%s)', (value) => {
|
||||
expect(sanitizedMark(value)).toBeNull();
|
||||
});
|
||||
|
||||
it('names a background color against the background side of the palette', () => {
|
||||
const doc = docOf(
|
||||
paragraph.create({ backgroundColor: 'rgb(251, 243, 219)' }, [
|
||||
schema.text('Highlighted'),
|
||||
]),
|
||||
);
|
||||
|
||||
const cleaned = sanitizeDocumentColors(stateOf(doc))!.doc;
|
||||
|
||||
expect(cleaned.firstChild!.attrs.backgroundColor).toBe('yellow');
|
||||
});
|
||||
|
||||
it('keeps the other marks of a renamed fragment', () => {
|
||||
const doc = docOf(
|
||||
paragraph.create(null, [
|
||||
schema.text('Blue and bold', [
|
||||
marks.bold.create(),
|
||||
colorMark('textColor', '#0b6e99'),
|
||||
]),
|
||||
]),
|
||||
);
|
||||
|
||||
const cleaned = sanitizeDocumentColors(stateOf(doc))!.doc;
|
||||
|
||||
const names = cleaned.firstChild!.firstChild!.marks.map((m) => m.type.name).sort();
|
||||
expect(names).toEqual(['bold', 'textColor']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeDocumentColors', () => {
|
||||
describe('inline style marks', () => {
|
||||
it('drops a textColor mark whose value is outside the palette', () => {
|
||||
const doc = docOf(
|
||||
paragraph.create(null, [
|
||||
schema.text('Pasted', [colorMark('textColor', 'rgb(51, 51, 51)')]),
|
||||
]),
|
||||
);
|
||||
|
||||
const cleaned = sanitizeDocumentColors(stateOf(doc))!.doc;
|
||||
|
||||
const child = cleaned.firstChild!.firstChild!;
|
||||
expect(child.text).toBe('Pasted');
|
||||
expect(child.marks).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('drops a backgroundColor mark whose value is outside the palette', () => {
|
||||
const doc = docOf(
|
||||
paragraph.create(null, [
|
||||
schema.text('Pasted', [colorMark('backgroundColor', '#ffffff')]),
|
||||
]),
|
||||
);
|
||||
|
||||
const cleaned = sanitizeDocumentColors(stateOf(doc))!.doc;
|
||||
|
||||
expect(cleaned.firstChild!.firstChild!.marks).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('keeps palette colors so pasting from another composer keeps its formatting', () => {
|
||||
const doc = docOf(
|
||||
paragraph.create(null, [
|
||||
schema.text('Blue', [colorMark('textColor', 'blue')]),
|
||||
]),
|
||||
);
|
||||
|
||||
expect(sanitizeDocumentColors(stateOf(doc))).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps non-color marks applied to a stripped fragment', () => {
|
||||
const doc = docOf(
|
||||
paragraph.create(null, [
|
||||
schema.text('Bold pasted', [
|
||||
marks.bold.create(),
|
||||
colorMark('textColor', 'rgb(0, 0, 0)'),
|
||||
]),
|
||||
]),
|
||||
);
|
||||
|
||||
const cleaned = sanitizeDocumentColors(stateOf(doc))!.doc;
|
||||
|
||||
const markNames = cleaned.firstChild!.firstChild!.marks.map((m) => m.type.name);
|
||||
expect(markNames).toEqual(['bold']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('block props', () => {
|
||||
it('resets off-palette block colors to default', () => {
|
||||
const doc = docOf(
|
||||
paragraph.create(
|
||||
{ textColor: 'rgb(51, 51, 51)', backgroundColor: 'transparent' },
|
||||
[schema.text('Pasted')],
|
||||
),
|
||||
);
|
||||
|
||||
const cleaned = sanitizeDocumentColors(stateOf(doc))!.doc;
|
||||
|
||||
expect(cleaned.firstChild!.attrs).toMatchObject({
|
||||
textColor: 'default',
|
||||
backgroundColor: 'default',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the other block props untouched', () => {
|
||||
const doc = docOf(
|
||||
paragraph.create({ textColor: '#123456', textAlignment: 'center' }, [
|
||||
schema.text('Pasted'),
|
||||
]),
|
||||
);
|
||||
|
||||
const cleaned = sanitizeDocumentColors(stateOf(doc))!.doc;
|
||||
|
||||
expect(cleaned.firstChild!.attrs.textAlignment).toBe('center');
|
||||
});
|
||||
|
||||
it('keeps palette block colors', () => {
|
||||
const doc = docOf(
|
||||
paragraph.create({ backgroundColor: 'yellow' }, [
|
||||
schema.text('Highlighted'),
|
||||
]),
|
||||
);
|
||||
|
||||
expect(sanitizeDocumentColors(stateOf(doc))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('cleans every block of a multi-block paste without losing content', () => {
|
||||
const doc = docOf(
|
||||
paragraph.create({ textColor: '#111111' }, [schema.text('First')]),
|
||||
paragraph.create(null, [
|
||||
schema.text('Second', [colorMark('textColor', '#222222')]),
|
||||
]),
|
||||
);
|
||||
|
||||
const cleaned = sanitizeDocumentColors(stateOf(doc))!.doc;
|
||||
|
||||
expect(cleaned.child(0).attrs.textColor).toBe('default');
|
||||
expect(cleaned.child(1).firstChild!.marks).toHaveLength(0);
|
||||
expect(cleaned.textContent).toBe('FirstSecond');
|
||||
});
|
||||
});
|
||||
|
||||
describe('paste sanitizer plugin', () => {
|
||||
it('cleans the document when a paste transaction is applied', () => {
|
||||
const doc = docOf(
|
||||
paragraph.create({ textColor: 'rgb(9, 9, 9)' }, [
|
||||
schema.text('Pasted', [colorMark('backgroundColor', '#fefefe')]),
|
||||
]),
|
||||
);
|
||||
|
||||
const state = stateWithPlugin(doc);
|
||||
const cleaned = state.apply(state.tr.setMeta('paste', true)).doc;
|
||||
|
||||
expect(cleaned.firstChild!.attrs.textColor).toBe('default');
|
||||
expect(cleaned.firstChild!.firstChild!.marks).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('leaves the document alone when the transaction is not a paste', () => {
|
||||
const doc = docOf(
|
||||
paragraph.create({ textColor: 'rgb(9, 9, 9)' }, [schema.text('Typed')]),
|
||||
);
|
||||
const state = stateWithPlugin(doc);
|
||||
|
||||
const after = state.apply(state.tr.insertText('!', 1)).doc;
|
||||
|
||||
expect(after.firstChild!.attrs.textColor).toBe('rgb(9, 9, 9)');
|
||||
});
|
||||
});
|
||||
|
||||
// Second line of defense: drafts saved before the sanitizer existed still carry
|
||||
// off-palette colors, so the exporter must refuse to emit them on its own.
|
||||
describe('exporting blocks parsed from foreign HTML', () => {
|
||||
it('keeps the colors carried by mail clients out of the sent HTML', async () => {
|
||||
const editor = BlockNoteEditor.create();
|
||||
const blocks = await editor.tryParseHTMLToBlocks(
|
||||
'<p style="color: rgb(51, 51, 51); background-color: #ffffff">' +
|
||||
'Hello <span style="color:#123456">World</span></p>',
|
||||
);
|
||||
|
||||
const html = new EmailExporter().exportBlocks(blocks, null);
|
||||
|
||||
expect(html).toBe('<p>Hello World</p>');
|
||||
});
|
||||
|
||||
// A pasted table hits the same trap through its cell props: the editor
|
||||
// shows plain cells, so the banded rows and colored headers of the source
|
||||
// table must not reappear in the sent mail.
|
||||
it('keeps the cell colors of a pasted table out of the sent HTML', async () => {
|
||||
const editor = BlockNoteEditor.create();
|
||||
const blocks = await editor.tryParseHTMLToBlocks(PASTED_TABLE_HTML);
|
||||
|
||||
const html = new EmailExporter().exportBlocks(blocks, null);
|
||||
|
||||
expect(html).not.toContain('background-color');
|
||||
expect(html).not.toContain('rgb(');
|
||||
expect(html).toContain('Alice');
|
||||
});
|
||||
});
|
||||
|
||||
describe('round-tripping our own exported HTML', () => {
|
||||
it('keeps a palette color when a sent mail is pasted back into a reply', async () => {
|
||||
const exporter = new EmailExporter();
|
||||
const sent = exporter.exportBlocks(
|
||||
[
|
||||
{
|
||||
id: 'x',
|
||||
type: 'paragraph',
|
||||
props: { textAlignment: 'left', textColor: 'blue', backgroundColor: 'yellow' },
|
||||
content: [{ type: 'text', text: 'Colored', styles: { textColor: 'red' } }],
|
||||
children: [],
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any,
|
||||
],
|
||||
null,
|
||||
);
|
||||
|
||||
const editor = BlockNoteEditor.create();
|
||||
editor.replaceBlocks(editor.document, await editor.tryParseHTMLToBlocks(sent));
|
||||
editor.exec((state, dispatch) => {
|
||||
dispatch!(sanitizeDocumentColors(state)!);
|
||||
return true;
|
||||
});
|
||||
|
||||
// Re-exporting the pasted content must yield the very same HTML.
|
||||
expect(exporter.exportBlocks(editor.document, null)).toBe(sent);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizing a pasted table', () => {
|
||||
it('resets the cell colors while preserving the table structure', async () => {
|
||||
const editor = BlockNoteEditor.create();
|
||||
const blocks = await editor.tryParseHTMLToBlocks(PASTED_TABLE_HTML);
|
||||
editor.replaceBlocks(editor.document, blocks);
|
||||
const state = editor.prosemirrorState;
|
||||
|
||||
const cleaned = state.apply(sanitizeDocumentColors(state)!).doc;
|
||||
|
||||
const cells: Record<string, unknown>[] = [];
|
||||
cleaned.descendants((node) => {
|
||||
if (node.type.name === 'tableCell' || node.type.name === 'tableHeader') {
|
||||
cells.push(node.attrs);
|
||||
}
|
||||
});
|
||||
expect(cells.length).toBeGreaterThan(0);
|
||||
for (const attrs of cells) {
|
||||
expect(attrs.backgroundColor).toBe('default');
|
||||
expect(attrs.textColor).toBe('default');
|
||||
// Layout attributes are none of the sanitizer's business.
|
||||
expect(attrs.colspan).toBe(1);
|
||||
}
|
||||
expect(cleaned.textContent).toContain('Alice');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { Extension } from '@tiptap/core';
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state';
|
||||
import type { EditorState, Transaction } from '@tiptap/pm/state';
|
||||
import { isBlockNoteColor, matchBlockNoteColorName } from './colors';
|
||||
|
||||
/**
|
||||
* Color-carrying names, shared by the block props (node attributes) and the
|
||||
* inline style marks — BlockNote uses the same names for both — mapped to the
|
||||
* side of the palette they must be matched against.
|
||||
*/
|
||||
const COLOR_VARIANTS: Record<string, 'text' | 'background'> = {
|
||||
textColor: 'text',
|
||||
backgroundColor: 'background',
|
||||
};
|
||||
|
||||
const COLOR_NAMES = Object.keys(COLOR_VARIANTS);
|
||||
|
||||
/**
|
||||
* Value BlockNote uses to mean "no color", i.e. the one it omits when rendering.
|
||||
*/
|
||||
const DEFAULT_COLOR = 'default';
|
||||
|
||||
/**
|
||||
* Builds the transaction normalizing every color the editor cannot render:
|
||||
* palette colors written as raw CSS are named back, the rest is reset.
|
||||
*
|
||||
* The whole document is swept rather than only the inserted range: the sweep
|
||||
* enforces the invariant, so anything already clean is left untouched and the
|
||||
* few off-palette values left over in an older draft get fixed on the way.
|
||||
*
|
||||
* @returns the cleaning transaction, or `null` when the document is already clean
|
||||
*/
|
||||
export function sanitizeDocumentColors(state: EditorState): Transaction | null {
|
||||
const tr = state.tr;
|
||||
let changed = false;
|
||||
|
||||
state.doc.descendants((node, pos) => {
|
||||
for (const name of COLOR_NAMES) {
|
||||
if (!(name in node.attrs) || isBlockNoteColor(node.attrs[name])) continue;
|
||||
const paletteName = matchBlockNoteColorName(node.attrs[name], COLOR_VARIANTS[name]);
|
||||
tr.setNodeAttribute(pos, name, paletteName ?? DEFAULT_COLOR);
|
||||
changed = true;
|
||||
}
|
||||
for (const mark of node.marks) {
|
||||
const variant = COLOR_VARIANTS[mark.type.name];
|
||||
if (!variant || isBlockNoteColor(mark.attrs.stringValue)) continue;
|
||||
|
||||
const paletteName = matchBlockNoteColorName(mark.attrs.stringValue, variant);
|
||||
const end = pos + node.nodeSize;
|
||||
tr.removeMark(pos, end, mark);
|
||||
if (paletteName) {
|
||||
tr.addMark(pos, end, mark.type.create({ ...mark.attrs, stringValue: paletteName }));
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
|
||||
return changed ? tr : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up after a paste or an external drop, once ProseMirror has inserted
|
||||
* the content.
|
||||
*
|
||||
* We deliberately do not hook `transformPasted`: ProseMirror only ever calls
|
||||
* the first handler it finds, and BlockNote already owns it to regenerate the
|
||||
* ids of pasted blocks.
|
||||
*/
|
||||
export const createPasteSanitizerPlugin = () =>
|
||||
new Plugin({
|
||||
key: new PluginKey('pasteColorSanitizer'),
|
||||
appendTransaction: (transactions, _oldState, newState) => {
|
||||
const inserted = transactions.some(
|
||||
(tr) => tr.getMeta('paste') || tr.getMeta('uiEvent') === 'drop',
|
||||
);
|
||||
return inserted ? sanitizeDocumentColors(newState) : null;
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Drops the colors BlockNote infers from pasted HTML but cannot render.
|
||||
*
|
||||
* BlockNote maps any `style="color: …"` / `style="background-color: …"` found
|
||||
* in the clipboard onto its `textColor` / `backgroundColor` props and marks,
|
||||
* whatever the value. Its stylesheet only renders the nine named palette
|
||||
* colors, so a `rgb(51, 51, 51)` inherited from Word or Gmail stays invisible
|
||||
* while composing — but the email exporter turns it back into an inline style,
|
||||
* littering the sent HTML with markup that spam filters read as
|
||||
* machine-generated. Since the user never sees those colors, dropping them on
|
||||
* paste keeps the composer WYSIWYG and the stored draft clean.
|
||||
*
|
||||
* Palette colors are kept rather than dropped: they are recognized even when
|
||||
* the clipboard hands them back as raw CSS — our own exporter writes them that
|
||||
* way (`color:#0b6e99`) — so replying to a Messages mail keeps its formatting.
|
||||
*/
|
||||
export const PasteColorSanitizer = Extension.create({
|
||||
name: 'pasteColorSanitizer',
|
||||
addProseMirrorPlugins() {
|
||||
return [createPasteSanitizerPlugin()];
|
||||
},
|
||||
});
|
||||
@@ -1,5 +1,11 @@
|
||||
import { BlockNoteEditor, BlockNoteSchema } from '@blocknote/core';
|
||||
import type { Block } from '@blocknote/core';
|
||||
import { backfillTemplateVariableContent, resolveTemplateVariables } from './utils';
|
||||
import {
|
||||
backfillTemplateVariableContent,
|
||||
dropUnsupportedBlocks,
|
||||
resolveTemplateVariables,
|
||||
SUPPORTED_BLOCK_SPECS,
|
||||
} from './utils';
|
||||
import {
|
||||
AnyInlineContent,
|
||||
bulletListItem,
|
||||
@@ -225,3 +231,100 @@ describe('backfillTemplateVariableContent', () => {
|
||||
expect(result[1].type).toBe('image');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SUPPORTED_BLOCK_SPECS', () => {
|
||||
it('leaves out the blocks the email exporter cannot render', () => {
|
||||
expect(SUPPORTED_BLOCK_SPECS).not.toHaveProperty('video');
|
||||
expect(SUPPORTED_BLOCK_SPECS).not.toHaveProperty('audio');
|
||||
expect(SUPPORTED_BLOCK_SPECS).not.toHaveProperty('file');
|
||||
});
|
||||
|
||||
it('keeps the blocks the composer relies on', () => {
|
||||
expect(SUPPORTED_BLOCK_SPECS).toHaveProperty('paragraph');
|
||||
expect(SUPPORTED_BLOCK_SPECS).toHaveProperty('heading');
|
||||
expect(SUPPORTED_BLOCK_SPECS).toHaveProperty('image');
|
||||
});
|
||||
|
||||
it('produces no block when media or a file is pasted', async () => {
|
||||
const editor = BlockNoteEditor.create({
|
||||
schema: BlockNoteSchema.create({ blockSpecs: SUPPORTED_BLOCK_SPECS }),
|
||||
});
|
||||
|
||||
const blocks = await editor.tryParseHTMLToBlocks(
|
||||
'<p>avant</p><video src="https://x.test/v.mp4"></video>' +
|
||||
'<audio src="https://x.test/a.mp3"></audio>' +
|
||||
'<embed src="https://x.test/doc.pdf" type="application/pdf"><p>apres</p>',
|
||||
);
|
||||
|
||||
expect(blocks.map((b) => b.type)).toEqual(['paragraph', 'paragraph']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dropUnsupportedBlocks', () => {
|
||||
const SUPPORTED = ['paragraph', 'image'];
|
||||
|
||||
it('removes a block whose type is not in the schema', () => {
|
||||
const blocks = [
|
||||
{ type: 'paragraph', content: 'hello' },
|
||||
{ type: 'video', props: { url: 'https://x.test/v.mp4' } },
|
||||
];
|
||||
|
||||
const result = dropUnsupportedBlocks(blocks, SUPPORTED);
|
||||
|
||||
expect(result.map((b) => b.type)).toEqual(['paragraph']);
|
||||
});
|
||||
|
||||
it('removes unsupported nested blocks while keeping their parent', () => {
|
||||
const blocks = [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: 'parent',
|
||||
children: [
|
||||
{ type: 'audio', props: { url: 'https://x.test/a.mp3' } },
|
||||
{ type: 'paragraph', content: 'kept' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = dropUnsupportedBlocks(blocks, SUPPORTED);
|
||||
|
||||
const children = result[0].children as Record<string, unknown>[];
|
||||
expect(children.map((b) => b.type)).toEqual(['paragraph']);
|
||||
expect(result[0].content).toBe('parent');
|
||||
});
|
||||
|
||||
it('keeps a block without a type, which BlockNote reads as a paragraph', () => {
|
||||
const blocks = [{ content: 'implicit paragraph' }];
|
||||
|
||||
expect(dropUnsupportedBlocks(blocks, SUPPORTED)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns the same blocks when everything is supported', () => {
|
||||
const blocks = [{ type: 'paragraph', content: 'a' }, { type: 'image', props: {} }];
|
||||
|
||||
expect(dropUnsupportedBlocks(blocks, SUPPORTED)).toHaveLength(2);
|
||||
});
|
||||
|
||||
// Guards the reason this filter exists: BlockNote throws when initialContent
|
||||
// holds a type it cannot construct, which would make the draft unopenable.
|
||||
it('makes a legacy draft holding a video block loadable again', () => {
|
||||
const schema = BlockNoteSchema.create({ blockSpecs: SUPPORTED_BLOCK_SPECS });
|
||||
const draft = [
|
||||
{ type: 'paragraph', content: 'hello' },
|
||||
{ type: 'video', props: { url: 'https://x.test/v.mp4' } },
|
||||
];
|
||||
|
||||
expect(() =>
|
||||
BlockNoteEditor.create({
|
||||
schema,
|
||||
initialContent: draft as never,
|
||||
}),
|
||||
).toThrow();
|
||||
|
||||
const editor = BlockNoteEditor.create({
|
||||
schema,
|
||||
initialContent: dropUnsupportedBlocks(draft, Object.keys(schema.blockSchema)) as never,
|
||||
});
|
||||
expect(editor.document.map((b) => b.type)).toEqual(['paragraph']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as locales from '@blocknote/core/locales';
|
||||
import { Block } from '@blocknote/core';
|
||||
import { Block, defaultBlockSpecs } from '@blocknote/core';
|
||||
import { TFunction } from 'i18next';
|
||||
import { ALLOWED_IMAGE_MIME_TYPES } from '@/features/blocknote/image-block';
|
||||
import { TEMPLATE_VARIABLE_TYPE } from '@/features/blocknote/inline-template-variable';
|
||||
@@ -44,16 +44,33 @@ export const createNonImageFileBlockers = () => ({
|
||||
},
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { video, audio, file, ...supportedBlockSpecs } = defaultBlockSpecs;
|
||||
|
||||
/**
|
||||
* The default BlockNote block specs, minus the media and file blocks.
|
||||
*
|
||||
* A `<video>` / `<audio>` / `<embed>` tag in pasted HTML used to create a
|
||||
* block: the editor displayed it, but the email exporter has no branch for
|
||||
* those types, so the content silently disappeared from the sent message. Out
|
||||
* of the schema, the pasted tag now yields no block at all.
|
||||
*
|
||||
* Files reach the composer through the clipboard/drop handlers instead, which
|
||||
* route anything that is not an image to the attachments — a path that never
|
||||
* goes through this schema.
|
||||
*/
|
||||
export const SUPPORTED_BLOCK_SPECS = supportedBlockSpecs;
|
||||
|
||||
/**
|
||||
* Block types to hide from the slash menu and BlockTypeSelect.
|
||||
* These blocks remain in the schema for backward-compatibility
|
||||
* (existing drafts may contain them) but are hidden from the UI.
|
||||
*
|
||||
* Video, audio and file are not listed here: they are out of the schemas
|
||||
* entirely, see {@link SUPPORTED_BLOCK_SPECS}.
|
||||
*/
|
||||
export const HIDDEN_BLOCK_TYPES = new Set([
|
||||
'toggleListItem',
|
||||
'file',
|
||||
'video',
|
||||
'audio',
|
||||
'table',
|
||||
]);
|
||||
|
||||
@@ -106,6 +123,41 @@ export const resolveTemplateVariables = (
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Removes the blocks whose type is not part of the editor schema.
|
||||
*
|
||||
* Video, audio and file blocks were reachable by pasting HTML carrying a
|
||||
* `<video>` / `<audio>` / `<embed>` tag: the editor displayed them, but the
|
||||
* email exporter has no branch for them, so they silently vanished from the
|
||||
* sent message. They are now out of the schemas — which makes BlockNote throw
|
||||
* on any draft saved back then, since it cannot build a document from a type it
|
||||
* does not know. Dropping them here keeps those drafts openable.
|
||||
*
|
||||
* Operates on the raw JSON blocks (pre-`useCreateBlockNote`), hence the loose
|
||||
* typing. Recurses into children blocks.
|
||||
*
|
||||
* @param blocks - the parsed draft content
|
||||
* @param supportedTypes - the block types the schema declares
|
||||
*/
|
||||
export const dropUnsupportedBlocks = (
|
||||
blocks: Record<string, unknown>[],
|
||||
supportedTypes: string[],
|
||||
): Record<string, unknown>[] =>
|
||||
blocks
|
||||
// A block without a type is a paragraph as far as BlockNote is concerned.
|
||||
.filter((block) => typeof block.type !== 'string' || supportedTypes.includes(block.type))
|
||||
.map((block) => {
|
||||
const children = block.children;
|
||||
if (!Array.isArray(children) || children.length === 0) return block;
|
||||
return {
|
||||
...block,
|
||||
children: dropUnsupportedBlocks(
|
||||
children as Record<string, unknown>[],
|
||||
supportedTypes,
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Backfills the styled `content` of legacy `template-variable` inline nodes.
|
||||
*
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
import { useCreateBlockNote } from "@blocknote/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BlockNoteEditor, BlockNoteEditorOptions, BlockNoteSchema, defaultBlockSpecs, PartialBlock } from '@blocknote/core';
|
||||
import { BlockNoteEditor, BlockNoteEditorOptions, BlockNoteSchema, PartialBlock } from '@blocknote/core';
|
||||
import { MessageTemplateSelector } from '@/features/blocknote/message-template-block';
|
||||
import { imageBlockSpec, ALLOWED_IMAGE_MIME_TYPES } from '@/features/blocknote/image-block';
|
||||
import { EmailExporter } from '@/features/blocknote/email-exporter';
|
||||
@@ -19,7 +19,10 @@ 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 { createBlockNoteDictionary, dropUnsupportedBlocks, SUPPORTED_BLOCK_SPECS } from '@/features/blocknote/utils';
|
||||
import { PasteColorSanitizer } from '@/features/blocknote/paste-sanitizer';
|
||||
import { handle } from '@/features/utils/errors';
|
||||
import { findOrphanInlineImages } from './orphan-inline-images';
|
||||
import { MessageFormValues } from '../message-form';
|
||||
import { DriveFile } from '../message-form/drive-attachment-picker';
|
||||
|
||||
@@ -29,7 +32,7 @@ export { ALLOWED_IMAGE_MIME_TYPES } from '@/features/blocknote/image-block';
|
||||
|
||||
export const BLOCKNOTE_SCHEMA = BlockNoteSchema.create({
|
||||
blockSpecs: {
|
||||
...defaultBlockSpecs,
|
||||
...SUPPORTED_BLOCK_SPECS,
|
||||
'image': imageBlockSpec,
|
||||
'signature': BlockSignature(),
|
||||
'quoted-message': QuotedMessageBlock(),
|
||||
@@ -42,6 +45,8 @@ export type MessageComposerInlineContentSchema = MessageComposerBlockNoteSchema[
|
||||
export type MessageComposerStyleSchema = MessageComposerBlockNoteSchema['styleSchema'];
|
||||
export type PartialMessageComposerBlockSchema = PartialBlock<MessageComposerBlockSchema, MessageComposerInlineContentSchema, MessageComposerStyleSchema>;
|
||||
|
||||
const SUPPORTED_BLOCK_TYPES = Object.keys(BLOCKNOTE_SCHEMA.blockSchema);
|
||||
|
||||
const emailExporter = new EmailExporter();
|
||||
|
||||
export type QuoteType = "reply" | "forward";
|
||||
@@ -129,7 +134,7 @@ export const MessageComposer = React.forwardRef<MessageComposerHandle, MessageCo
|
||||
|
||||
// Intercept non-image file drops/pastes before BlockNote processes them.
|
||||
// Without this, BlockNote routes unknown MIME types to the "file" block
|
||||
// (removed from schema) which causes a crash.
|
||||
// (kept out of the schema, see SUPPORTED_BLOCK_SPECS) which causes a crash.
|
||||
//
|
||||
// BlockNote's SideMenu plugin dispatches synthetic drop events (isTrusted=false)
|
||||
// on the editor when the real drop lands within 250px of the editor bounds.
|
||||
@@ -153,9 +158,18 @@ export const MessageComposer = React.forwardRef<MessageComposerHandle, MessageCo
|
||||
* to display a preview of the quoted message.
|
||||
*/
|
||||
const getInitialContent = () => {
|
||||
// Parse initial content
|
||||
const initialContent = defaultValue
|
||||
? JSON.parse(defaultValue)
|
||||
// Parse initial content, dropping the blocks the schema no longer knows
|
||||
// about so a draft saved with a pasted video/audio still opens.
|
||||
let parsedContent: Record<string, unknown>[] = [];
|
||||
if (defaultValue) {
|
||||
try {
|
||||
parsedContent = dropUnsupportedBlocks(JSON.parse(defaultValue), SUPPORTED_BLOCK_TYPES);
|
||||
} catch (error) {
|
||||
handle(new Error("Error parsing initial content."), { extra: { error, defaultValue } });
|
||||
}
|
||||
}
|
||||
const initialContent = parsedContent.length > 0
|
||||
? parsedContent
|
||||
: [{ type: "paragraph", content: "" }];
|
||||
|
||||
if (!quotedMessage) return initialContent;
|
||||
@@ -184,7 +198,7 @@ export const MessageComposer = React.forwardRef<MessageComposerHandle, MessageCo
|
||||
dictionary: createBlockNoteDictionary(locale, t),
|
||||
...blockNoteOptions,
|
||||
_tiptapOptions: {
|
||||
extensions: [SmartTrailingBlock],
|
||||
extensions: [SmartTrailingBlock, PasteColorSanitizer],
|
||||
editorProps: {
|
||||
handleDOMEvents: {
|
||||
blur: (_view: unknown, event: FocusEvent) => {
|
||||
@@ -424,7 +438,9 @@ export const MessageComposer = React.forwardRef<MessageComposerHandle, MessageCo
|
||||
}, [editor, draft?.id]);
|
||||
|
||||
// Sync direction: attachments → editor.
|
||||
// Removes image blocks whose attachment was deleted externally (e.g. via AttachmentUploader).
|
||||
// Removes image blocks whose inline attachment was deleted externally (e.g. via
|
||||
// AttachmentUploader). Images that never had an attachment — a remote URL, one
|
||||
// typed in the image toolbar — are left untouched, see findOrphanInlineImages.
|
||||
// The reverse direction (editor → attachments) lives in handleChange above.
|
||||
// No loop occurs because:
|
||||
// - This effect removing a block triggers handleChange, but the attachment is already gone
|
||||
@@ -433,14 +449,12 @@ export const MessageComposer = React.forwardRef<MessageComposerHandle, MessageCo
|
||||
// when the attachment is already absent, so this effect doesn't re-fire.
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
const inlineImages = editor.document.filter(block => block.type === 'image');
|
||||
if (inlineImages.length === 0) return;
|
||||
const blobAttachments = attachments.filter((a): a is Attachment => 'blobId' in a);
|
||||
const inlineImagesToRemove = inlineImages.filter(image =>
|
||||
editor.getBlock(image.id) && !blobAttachments.some(a => image.props.url.includes(a.blobId)),
|
||||
const attachedBlobIds = new Set(
|
||||
attachments.filter((a): a is Attachment => 'blobId' in a).map(a => a.blobId),
|
||||
);
|
||||
if (inlineImagesToRemove.length > 0) {
|
||||
editor.removeBlocks(inlineImagesToRemove.map(image => image.id));
|
||||
const orphanIds = findOrphanInlineImages(editor.document, attachedBlobIds);
|
||||
if (orphanIds.length > 0) {
|
||||
editor.removeBlocks(orphanIds);
|
||||
}
|
||||
}, [attachments]);
|
||||
|
||||
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
import { findOrphanInlineImages } from './orphan-inline-images';
|
||||
|
||||
const BLOB_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||
const OTHER_BLOB_ID = 'ffffffff-0000-1111-2222-333333333333';
|
||||
|
||||
const blobUrl = (id: string) => `/api/v1.0/blob/${id}/download/`;
|
||||
|
||||
const image = (id: string, url: string) => ({ id, type: 'image', props: { url } });
|
||||
|
||||
describe('findOrphanInlineImages', () => {
|
||||
it('removes an uploaded image whose attachment is gone', () => {
|
||||
const blocks = [image('img-1', blobUrl(BLOB_ID))];
|
||||
|
||||
expect(findOrphanInlineImages(blocks, new Set())).toEqual(['img-1']);
|
||||
});
|
||||
|
||||
it('keeps an uploaded image whose attachment is still there', () => {
|
||||
const blocks = [image('img-1', blobUrl(BLOB_ID))];
|
||||
|
||||
expect(findOrphanInlineImages(blocks, new Set([BLOB_ID]))).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps an uploaded image when another attachment was removed', () => {
|
||||
const blocks = [image('img-1', blobUrl(BLOB_ID))];
|
||||
|
||||
expect(findOrphanInlineImages(blocks, new Set([BLOB_ID, OTHER_BLOB_ID]))).toEqual([]);
|
||||
});
|
||||
|
||||
// The regression that emptied drafts: an image the user pasted or typed a URL
|
||||
// for never had an attachment, so it must survive an empty attachment list.
|
||||
it.each([
|
||||
['a remote URL', 'https://example.com/photo.png'],
|
||||
['a data URI', 'data:image/png;base64,iVBORw0KGgo='],
|
||||
['an object URL', 'blob:http://localhost:8900/8f3b-4a1c'],
|
||||
['an empty URL', ''],
|
||||
])('keeps an image with %s even with no attachments', (_label, url) => {
|
||||
expect(findOrphanInlineImages([image('img-1', url)], new Set())).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps an image block with no props at all', () => {
|
||||
expect(findOrphanInlineImages([{ id: 'img-1', type: 'image' }], new Set())).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores blocks that are not images', () => {
|
||||
const blocks = [
|
||||
{ id: 'p-1', type: 'paragraph', props: { url: blobUrl(BLOB_ID) } },
|
||||
];
|
||||
|
||||
expect(findOrphanInlineImages(blocks, new Set())).toEqual([]);
|
||||
});
|
||||
|
||||
it('reaches images nested in a column layout', () => {
|
||||
const blocks = [
|
||||
{
|
||||
id: 'cols',
|
||||
type: 'columnList',
|
||||
children: [
|
||||
{
|
||||
id: 'col-1',
|
||||
type: 'column',
|
||||
children: [
|
||||
image('nested-orphan', blobUrl(BLOB_ID)),
|
||||
image('nested-remote', 'https://example.com/photo.png'),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
expect(findOrphanInlineImages(blocks, new Set())).toEqual(['nested-orphan']);
|
||||
});
|
||||
|
||||
it('returns every orphan of a mixed document', () => {
|
||||
const blocks = [
|
||||
{ id: 'p-1', type: 'paragraph' },
|
||||
image('kept-attached', blobUrl(BLOB_ID)),
|
||||
image('kept-remote', 'https://example.com/photo.png'),
|
||||
image('orphan', blobUrl(OTHER_BLOB_ID)),
|
||||
];
|
||||
|
||||
expect(findOrphanInlineImages(blocks, new Set([BLOB_ID]))).toEqual(['orphan']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import MailHelper from '@/features/utils/mail-helper';
|
||||
|
||||
/**
|
||||
* Structural view of the blocks this module cares about, so it can be tested
|
||||
* without building a whole BlockNote document.
|
||||
*/
|
||||
type BlockLike = {
|
||||
id: string;
|
||||
type: string;
|
||||
props?: unknown;
|
||||
children?: readonly BlockLike[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Reads the `url` prop of a block. Typed loosely on purpose: block props differ
|
||||
* from one block type to the next, and only the image ones carry a URL.
|
||||
*/
|
||||
const urlOf = (block: BlockLike): string => {
|
||||
const { props } = block;
|
||||
if (!props || typeof props !== 'object' || !('url' in props)) return '';
|
||||
const url = (props as { url?: unknown }).url;
|
||||
return typeof url === 'string' ? url : '';
|
||||
};
|
||||
|
||||
/**
|
||||
* Collects the image blocks whose inline attachment is gone.
|
||||
*
|
||||
* Only images uploaded through our own pipeline are candidates: their URL is a
|
||||
* blob download URL, so a missing blob id means the user deleted the attachment
|
||||
* elsewhere (e.g. in the AttachmentUploader) and the block must follow.
|
||||
*
|
||||
* Every other image is left alone. A remote URL, a `data:` URI or an address
|
||||
* typed into the image toolbar never had an attachment to begin with, so its
|
||||
* absence from the list proves nothing — treating it as orphaned would delete
|
||||
* images the user legitimately added.
|
||||
*
|
||||
* @param blocks - the editor document, traversed recursively (images can live
|
||||
* inside a column layout)
|
||||
* @param attachedBlobIds - blob ids of the inline attachments still present
|
||||
* @returns the ids of the blocks to remove
|
||||
*/
|
||||
export const findOrphanInlineImages = (
|
||||
blocks: readonly BlockLike[],
|
||||
attachedBlobIds: Set<string>,
|
||||
): string[] => {
|
||||
const orphans: string[] = [];
|
||||
|
||||
const visit = (currentBlocks: readonly BlockLike[]) => {
|
||||
for (const block of currentBlocks) {
|
||||
if (block.type === 'image') {
|
||||
const blobId = MailHelper.extractBlobId(urlOf(block));
|
||||
if (blobId && !attachedBlobIds.has(blobId)) {
|
||||
orphans.push(block.id);
|
||||
}
|
||||
}
|
||||
if (block.children?.length) {
|
||||
visit(block.children);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
visit(blocks);
|
||||
return orphans;
|
||||
};
|
||||
+3
-2
@@ -1,5 +1,5 @@
|
||||
import { BlockNoteViewField } from "@/features/blocknote/blocknote-view-field";
|
||||
import { BlockNoteEditor, BlockNoteEditorOptions, BlockNoteSchema, defaultBlockSpecs, defaultInlineContentSpecs } from "@blocknote/core";
|
||||
import { BlockNoteEditor, BlockNoteEditorOptions, BlockNoteSchema, defaultInlineContentSpecs } from "@blocknote/core";
|
||||
import { buildTemplateVariableInsertion, InlineTemplateVariable, TemplateVariableSelector } from "@/features/blocknote/inline-template-variable";
|
||||
import { TemplateVariableEditingBehavior } from "@/features/blocknote/inline-template-variable/editing-behavior";
|
||||
import { usePlaceholderVariables } from "@/features/blocknote/inline-template-variable/use-placeholder-variables";
|
||||
@@ -11,6 +11,7 @@ import { BlockSignature, BlockSignatureConfigProps, SignatureTemplateSelector }
|
||||
import { MessageTemplateTypeChoices, useMailboxesMessageTemplatesAvailableList } from "@/features/api/gen";
|
||||
import { useMailboxContext } from "@/features/providers/mailbox";
|
||||
import { imageBlockSpec } from "@/features/blocknote/image-block";
|
||||
import { SUPPORTED_BLOCK_SPECS } from "@/features/blocknote/utils";
|
||||
import { SmartTrailingBlock } from "@/features/blocknote/smart-trailing-block";
|
||||
import { useBase64Composer, Base64ComposerHandle } from "@/features/blocknote/hooks/use-base64-composer";
|
||||
import { extractSignatureId } from "../utils";
|
||||
@@ -19,7 +20,7 @@ import { filterSuggestionItems } from "@blocknote/core/extensions";
|
||||
|
||||
const TEMPLATE_BLOCKNOTE_SCHEMA = BlockNoteSchema.create({
|
||||
blockSpecs: {
|
||||
...defaultBlockSpecs,
|
||||
...SUPPORTED_BLOCK_SPECS,
|
||||
'image': imageBlockSpec,
|
||||
'signature': BlockSignature(),
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BlockNoteViewField } from "@/features/blocknote/blocknote-view-field";
|
||||
import { BlockNoteEditor, BlockNoteEditorOptions, BlockNoteSchema, defaultBlockSpecs, defaultInlineContentSpecs, PartialBlock } from "@blocknote/core";
|
||||
import { BlockNoteEditor, BlockNoteEditorOptions, BlockNoteSchema, defaultInlineContentSpecs, PartialBlock } from "@blocknote/core";
|
||||
import { filterSuggestionItems } from "@blocknote/core/extensions";
|
||||
import { SuggestionMenuController } from "@blocknote/react";
|
||||
import { FieldProps } from "@gouvfr-lasuite/cunningham-react";
|
||||
@@ -10,12 +10,13 @@ import { TemplateVariableEditingBehavior } from "@/features/blocknote/inline-tem
|
||||
import { usePlaceholderVariables } from "@/features/blocknote/inline-template-variable/use-placeholder-variables";
|
||||
import { Toolbar } from "@/features/blocknote/toolbar";
|
||||
import { imageBlockSpec } from "@/features/blocknote/image-block";
|
||||
import { SUPPORTED_BLOCK_SPECS } from "@/features/blocknote/utils";
|
||||
import { useBase64Composer, Base64ComposerHandle } from "@/features/blocknote/hooks/use-base64-composer";
|
||||
import { ColumnBlock, ColumnListBlock } from "@/features/blocknote/column-layout-block";
|
||||
|
||||
const SIGNATURE_BLOCKNOTE_SCHEMA = BlockNoteSchema.create({
|
||||
blockSpecs: {
|
||||
...defaultBlockSpecs,
|
||||
...SUPPORTED_BLOCK_SPECS,
|
||||
'image': imageBlockSpec,
|
||||
'column': ColumnBlock,
|
||||
'columnList': ColumnListBlock,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { vi } from 'vitest';
|
||||
import MailHelper, { SUPPORTED_IMAP_DOMAINS, ATTACHMENT_SEPARATORS } from './mail-helper';
|
||||
import DetectionMap from '@/features/i18n/attachments-detection-map.json';
|
||||
import i18n from '@/features/i18n/initI18n';
|
||||
import { getApiOrigin } from '@/features/api/utils';
|
||||
|
||||
vi.mock('./errors', () => ({ handle: vi.fn() }));
|
||||
|
||||
@@ -813,6 +814,54 @@ describe('MailHelper', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractBlobId', () => {
|
||||
const blobId = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||
|
||||
it('should read the id from an absolute blob URL on the API origin', () => {
|
||||
expect(
|
||||
MailHelper.extractBlobId(`${getApiOrigin()}/api/v1.0/blob/${blobId}/download/`),
|
||||
).toBe(blobId);
|
||||
});
|
||||
|
||||
it('should read the id from a relative blob URL', () => {
|
||||
expect(MailHelper.extractBlobId(`/api/v1.0/blob/${blobId}/download/`)).toBe(blobId);
|
||||
});
|
||||
|
||||
it('should read the id from a URL on the configured API origin', () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_API_ORIGIN', 'https://api.example.test');
|
||||
try {
|
||||
expect(
|
||||
MailHelper.extractBlobId(`https://api.example.test/api/v1.0/blob/${blobId}/download/`),
|
||||
).toBe(blobId);
|
||||
// The window origin is no longer the API: it must stop being accepted.
|
||||
expect(
|
||||
MailHelper.extractBlobId(`${window.location.origin}/api/v1.0/blob/${blobId}/download/`),
|
||||
).toBeNull();
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a remote image', 'https://example.com/photo.png'],
|
||||
['a data URI', 'data:image/png;base64,iVBORw0KGgo='],
|
||||
['an object URL', 'blob:http://localhost:8900/8f3b-4a1c'],
|
||||
['an empty string', ''],
|
||||
])('should return null for %s', (_label, url) => {
|
||||
expect(MailHelper.extractBlobId(url)).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
// Anchored matching: only the URL we built ourselves designates an attachment.
|
||||
['a URL that merely embeds a blob path', `https://evil.test/redirect?to=/api/v1.0/blob/${blobId}/download/`],
|
||||
['an arbitrary origin', `https://cdn.example/api/v1.0/blob/${blobId}/download/`],
|
||||
['a trailing path segment', `${getApiOrigin()}/api/v1.0/blob/${blobId}/download/extra`],
|
||||
['a trailing query string', `${getApiOrigin()}/api/v1.0/blob/${blobId}/download/?x=1`],
|
||||
])('should return null for %s', (_label, url) => {
|
||||
expect(MailHelper.extractBlobId(url)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('dataUrlToFile', () => {
|
||||
it('should convert a valid PNG data URL to a File', () => {
|
||||
// 1x1 red PNG as base64
|
||||
|
||||
@@ -5,6 +5,7 @@ import z from "zod";
|
||||
import { DriveFile } from "../forms/components/message-form/drive-attachment-picker";
|
||||
import { handle } from "./errors";
|
||||
import { getBlobDownloadRetrieveUrl } from "@/features/api/gen/blob/blob";
|
||||
import { getApiOrigin } from "@/features/api/utils";
|
||||
|
||||
/**
|
||||
* Decode HTML entities produced by renderToStaticMarkup in attribute values.
|
||||
@@ -59,6 +60,23 @@ const ATTACHMENT_SEPARATORS_BY_LANG: Record<string, string> = {
|
||||
const getAttachmentSeparator = (): string =>
|
||||
ATTACHMENT_SEPARATORS_BY_LANG[i18n.language] ?? ATTACHMENT_SEPARATORS[0];
|
||||
|
||||
const escapeRegex = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
|
||||
/**
|
||||
* Regex source matching the path of a blob download URL, with the blob id as
|
||||
* first group.
|
||||
*
|
||||
* Derived from the Orval-generated getBlobDownloadRetrieveUrl so it stays in
|
||||
* sync with the API spec. Carries no origin: callers prepend the prefix that
|
||||
* matches how strict they need to be.
|
||||
*/
|
||||
const blobUrlRegexSource = (): string => {
|
||||
const placeholder = '__BLOB_ID__';
|
||||
// Escape regex special chars in the template, then replace the placeholder with a capture group
|
||||
return escapeRegex(getBlobDownloadRetrieveUrl(placeholder))
|
||||
.replace(placeholder, '([a-f0-9-]+)');
|
||||
};
|
||||
|
||||
/** An helper which aims to gather all utils related write and send a message */
|
||||
class MailHelper {
|
||||
|
||||
@@ -66,21 +84,34 @@ class MailHelper {
|
||||
* Replace blob download URLs in HTML with cid: references for email embedding.
|
||||
* This converts image sources from API URLs to Content-ID references
|
||||
* that email clients can resolve using the MIME multipart/related structure.
|
||||
*
|
||||
* The URL pattern is derived from the Orval-generated getBlobDownloadRetrieveUrl
|
||||
* so it stays in sync with the API spec.
|
||||
*/
|
||||
static replaceBlobUrlsWithCid(html: string): string {
|
||||
// Use the Orval-generated URL function with a placeholder to derive the pattern
|
||||
const placeholder = '__BLOB_ID__';
|
||||
const urlTemplate = getBlobDownloadRetrieveUrl(placeholder);
|
||||
// Escape regex special chars in the template, then replace the placeholder with a capture group
|
||||
const pattern = urlTemplate
|
||||
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
.replace(placeholder, '([a-f0-9-]+)');
|
||||
// Allow an optional origin prefix (full URLs from getRequestUrl)
|
||||
const regex = new RegExp(`(?:https?://[^/]+)?${pattern}`, 'g');
|
||||
return html.replace(regex, 'cid:$1');
|
||||
// Origin-agnostic on purpose: a draft written against another API origin
|
||||
// (dev vs prod, mobile webview) must still get its images embedded, and
|
||||
// rewriting a foreign URL to a cid: reference only ever removes a remote
|
||||
// fetch from the sent email.
|
||||
return html.replace(new RegExp(`(?:https?://[^/]+)?${blobUrlRegexSource()}`, 'g'), 'cid:$1');
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the blob id out of a blob download URL.
|
||||
*
|
||||
* Fully anchored, and restricted to our own API: the URL must *be* one of
|
||||
* the download URLs we built, not merely start with or contain something
|
||||
* that looks like one. A caller acts on the returned id (dropping the image
|
||||
* block that carries it), so a URL hosted elsewhere must not be able to
|
||||
* impersonate an attachment.
|
||||
*
|
||||
* @param url - the URL to inspect
|
||||
* @returns the blob id, or `null` for any other URL — a remote address, a
|
||||
* `data:` URI or a hand-typed link never went through our upload, so it
|
||||
* has no attachment to be matched against.
|
||||
*/
|
||||
static extractBlobId(url: string): string | null {
|
||||
if (!url) return null;
|
||||
const apiOrigin = getApiOrigin();
|
||||
const originPrefix = apiOrigin ? `(?:${escapeRegex(apiOrigin)})?` : '';
|
||||
return new RegExp(`^${originPrefix}${blobUrlRegexSource()}$`).exec(url)?.[1] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user