From 3138c2bd04f9f91e68fcf98f5291cbea096d2865 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Tue, 13 Oct 2026 16:58:19 +0200
Subject: [PATCH] fix: stop dropped files from inserting their path into the
message body
---
.../__tests__/rich-text-editor-drop.test.tsx | 75 +++++++++++++++++++
components/email/rich-text-editor.tsx | 13 ++--
2 files changed, 83 insertions(+), 5 deletions(-)
create mode 100644 components/email/__tests__/rich-text-editor-drop.test.tsx
diff --git a/components/email/__tests__/rich-text-editor-drop.test.tsx b/components/email/__tests__/rich-text-editor-drop.test.tsx
new file mode 100644
index 000000000..235cffce7
--- /dev/null
+++ b/components/email/__tests__/rich-text-editor-drop.test.tsx
@@ -0,0 +1,75 @@
+import { act, render, waitFor } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+import React from 'react';
+import type { Editor } from '@tiptap/core';
+
+import { RichTextEditor } from '../rich-text-editor';
+
+/** A drop from the desktop: the file plus its path as text, as Firefox sends it. */
+function fileDrop(file: File) {
+ const text = `file:///C:/Users/me/Downloads/${file.name}`;
+ const event = new Event('drop', { bubbles: true, cancelable: true }) as DragEvent;
+ Object.defineProperty(event, 'dataTransfer', {
+ value: {
+ files: [file],
+ types: ['Files', 'text/uri-list', 'text/plain'],
+ getData: (type: string) => (type === 'text/plain' || type === 'text/uri-list' ? text : ''),
+ },
+ });
+ return event;
+}
+
+async function setup(onImageUpload?: (file: File) => Promise<{ src: string } | null>) {
+ let editor: Editor | null = null;
+ const onDrop = vi.fn();
+ render(
+
+ {}}
+ onImageUpload={onImageUpload}
+ onEditorReady={(ed) => { editor = ed as unknown as Editor; }}
+ />
+
+ );
+ await waitFor(() => expect(editor).not.toBeNull());
+ const ed = editor as unknown as Editor;
+ // jsdom has no layout to resolve the drop point from.
+ ed.view.posAtCoords = () => ({ pos: 1, inside: -1 });
+ return { editor: ed, onDrop };
+}
+
+describe('RichTextEditor file drop', () => {
+ it('does not insert the path of a dropped file, and leaves the file to the composer', async () => {
+ const { editor, onDrop } = await setup(async () => ({ src: 'blob:x' }));
+ const event = fileDrop(new File(['%PDF'], 'report.pdf', { type: 'application/pdf' }));
+
+ act(() => { editor.view.dom.dispatchEvent(event); });
+
+ expect(editor.getText()).toBe('Hello');
+ expect(event.defaultPrevented).toBe(true);
+ expect(onDrop).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not insert the path when there is no inline image upload', async () => {
+ const { editor, onDrop } = await setup();
+ const event = fileDrop(new File(['png'], 'photo.png', { type: 'image/png' }));
+
+ act(() => { editor.view.dom.dispatchEvent(event); });
+
+ expect(editor.getText()).toBe('Hello');
+ expect(onDrop).toHaveBeenCalledTimes(1);
+ });
+
+ it('still inlines a dropped image instead of attaching it', async () => {
+ const upload = vi.fn(async () => ({ src: 'blob:x' }));
+ const { editor, onDrop } = await setup(upload);
+ const file = new File(['png'], 'photo.png', { type: 'image/png' });
+
+ act(() => { editor.view.dom.dispatchEvent(fileDrop(file)); });
+
+ expect(upload).toHaveBeenCalledWith(file);
+ expect(editor.getText()).toBe('Hello');
+ expect(onDrop).not.toHaveBeenCalled();
+ });
+});
diff --git a/components/email/rich-text-editor.tsx b/components/email/rich-text-editor.tsx
index bd8bd9aa0..35413bd10 100644
--- a/components/email/rich-text-editor.tsx
+++ b/components/email/rich-text-editor.tsx
@@ -293,12 +293,15 @@ export function RichTextEditor({
class: "tiptap min-h-[100px] px-4 py-3 text-sm text-foreground",
},
handleDrop: (view, event) => {
+ if (!event.dataTransfer?.files?.length) return false;
const upload = onImageUploadRef.current;
- if (!upload || !event.dataTransfer?.files?.length) return false;
- const imageFiles = Array.from(event.dataTransfer.files).filter(f =>
- f.type.startsWith("image/")
- );
- if (imageFiles.length === 0) return false;
+ const imageFiles = upload
+ ? Array.from(event.dataTransfer.files).filter(f => f.type.startsWith("image/"))
+ : [];
+ // A file dragged from the desktop also carries its path as text
+ // (file:///...), which ProseMirror would insert. Claim the drop but
+ // let it bubble so the composer attaches the file.
+ if (!upload || imageFiles.length === 0) return true;
event.preventDefault();
event.stopPropagation();
for (const file of imageFiles) {