From 49a46d78546632a95f3e3da75db4464690aabe5c Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Tue, 3 Nov 2026 15:44:17 +0100
Subject: [PATCH] fix: stop a mail row dragged onto a composer field typing its
subject there
---
.../[locale]/pro/__tests__/pro-gate.test.tsx | 20 +++++++++++++-
app/(main)/[locale]/pro/page.tsx | 4 ++-
.../__tests__/composer-close-guard.test.tsx | 27 +++++++++++++++++++
components/email/email-composer.tsx | 20 +++++++++-----
4 files changed, 63 insertions(+), 8 deletions(-)
diff --git a/app/(main)/[locale]/pro/__tests__/pro-gate.test.tsx b/app/(main)/[locale]/pro/__tests__/pro-gate.test.tsx
index 3844af6d5..799b73fd5 100644
--- a/app/(main)/[locale]/pro/__tests__/pro-gate.test.tsx
+++ b/app/(main)/[locale]/pro/__tests__/pro-gate.test.tsx
@@ -63,7 +63,7 @@ vi.mock('@/hooks/use-deep-link-url', () => ({ useDeepLinkUrl: () => {} }));
const { default: ProHome } = await import('../page');
const { useSettingsStore } = await import('@/stores/settings-store');
const { useProTabStore, takeHandedOffComposeDraft } = await import('@/stores/pro-tab-store');
-const { PRO_TAB_DRAG_MIME } = await import('@/components/pro/pro-shell-drop');
+const { EMAIL_IDS_DRAG_MIME, PRO_TAB_DRAG_MIME } = await import('@/components/pro/pro-shell-drop');
const replace = vi.fn();
@@ -176,6 +176,24 @@ describe('Pro page gate', () => {
expect(useProTabStore.getState().tabs.find((t) => t.id === 'cal')?.paneId).toBe('split');
});
+ it('stops a message row dropped where it can go nowhere, so no field takes its subject', async () => {
+ auth.state.isAuthenticated = true;
+ auth.state.client = {};
+ const main = { id: 'home-mail', kind: 'mail' as const, labelKey: 'mail', closeable: false, paneId: 'main' as const };
+ useProTabStore.setState({
+ tabs: [main], activeTabId: main.id, activeSplitTabId: null, focusedPaneId: 'main',
+ splitOrientation: null, splitSide: 'right', readerTabId: null, loadedTabIds: ['home-mail'],
+ });
+ const { container } = render();
+ await act(async () => {});
+ const surface = container.querySelector('[data-testid="mail-app"]')!;
+ const dataTransfer = { types: [EMAIL_IDS_DRAG_MIME, 'text/plain'], getData: () => '["m1"]', dropEffect: '' };
+ // No layout in jsdom: the position resolves to the middle, not a split edge.
+ const notPrevented = fireEvent.drop(surface, { dataTransfer });
+ expect(notPrevented).toBe(false);
+ expect(useProTabStore.getState().tabs).toHaveLength(1);
+ });
+
it('leaves a tab dropped on a tab strip to the strip', async () => {
auth.state.isAuthenticated = true;
auth.state.client = {};
diff --git a/app/(main)/[locale]/pro/page.tsx b/app/(main)/[locale]/pro/page.tsx
index 737338f90..948fc1c30 100644
--- a/app/(main)/[locale]/pro/page.tsx
+++ b/app/(main)/[locale]/pro/page.tsx
@@ -433,8 +433,10 @@ export default function ProHome() {
if (!kind) return;
const target = resolveTargetForEvent(e);
setDraggedTabId(null);
- if (!target) return;
+ // Also with nowhere to put it: a field under the pointer would take the text
+ // a message row carries.
e.preventDefault();
+ if (!target) return;
if (kind === 'tab') {
const draggedId = e.dataTransfer.getData(PRO_TAB_DRAG_MIME);
diff --git a/components/email/__tests__/composer-close-guard.test.tsx b/components/email/__tests__/composer-close-guard.test.tsx
index 8ac5e5b82..b91aa9d86 100644
--- a/components/email/__tests__/composer-close-guard.test.tsx
+++ b/components/email/__tests__/composer-close-guard.test.tsx
@@ -1,3 +1,4 @@
+import { PaneIdContext } from '@/hooks/use-pane-context';
import { render, screen, fireEvent, waitFor, act, within } from '@testing-library/react';
import { beforeEach, describe, it, expect, vi } from 'vitest';
import React from 'react';
@@ -183,6 +184,32 @@ vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) =>
// ─── Tests ────────────────────────────────────────────────────────────────────
+describe('a message row dragged over the composer', () => {
+ const rowDrag = { types: ['application/x-email-ids', 'text/plain'], files: [], getData: () => 'Subject of the row', dropEffect: '' };
+ const subjectField = () => screen.getByDisplayValue('Half-written');
+ const draft = {
+ to: '', cc: '', bcc: '', subject: 'Half-written', body: '', showCc: false, showBcc: false,
+ selectedIdentityId: 'id-me', subAddressTag: '', mode: 'compose' as const, draftId: null,
+ };
+
+ it('is stopped in the standard shell, or Subject would take the row\'s subject', () => {
+ render();
+ // fireEvent returns false when the event's default was prevented.
+ expect(fireEvent.dragOver(subjectField(), { dataTransfer: rowDrag })).toBe(false);
+ expect(fireEvent.drop(subjectField(), { dataTransfer: rowDrag })).toBe(false);
+ });
+
+ it('is left to the shell inside a Pro pane', () => {
+ render(
+
+
+ ,
+ );
+ expect(fireEvent.dragOver(subjectField(), { dataTransfer: rowDrag })).toBe(true);
+ expect(fireEvent.drop(subjectField(), { dataTransfer: rowDrag })).toBe(true);
+ });
+});
+
/**
* A host that replaces the compose session - a mailto: click, say - must not
* drop text the user has not sent. It hands a follow-up to requestCloseRef and
diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx
index cf4b1da6a..57f0fd5ec 100644
--- a/components/email/email-composer.tsx
+++ b/components/email/email-composer.tsx
@@ -80,6 +80,7 @@ import { useAccountStore } from "@/stores/account-store";
import { useSettingsStore } from "@/stores/settings-store";
import { ComposerPanelToggle, ComposerPluginSidebar, useComposerSidebars } from "./composer-plugin-sidebars";
import { dragKindFromTypes } from "@/components/pro/pro-shell-drop";
+import { useIsPaneScoped } from "@/hooks/use-pane-context";
import { ComposerSelectionMenu, useComposerSelectionMenu } from "./composer-selection-menu";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers";
@@ -170,8 +171,13 @@ import { registerComposeFlush } from "@/lib/compose-flush-registry";
import { toUnicodeEmail } from "@/lib/idn";
// A Pro tab, message or folder dragged across a composer tab is on its way to
-// the pane or the split edge: those drags belong to the shell.
-const isShellDrag = (e: React.DragEvent) => dragKindFromTypes(e.dataTransfer.types) !== null;
+// the pane or the split edge: those drags belong to the shell. There is no shell
+// outside Pro, and the composer stops them like any other drag: a message row
+// carries its subject as text, which To and Subject would take.
+function useIsShellDrag() {
+ const isPaneScoped = useIsPaneScoped();
+ return useCallback((e: React.DragEvent) => isPaneScoped && dragKindFromTypes(e.dataTransfer.types) !== null, [isPaneScoped]);
+}
/**
* Derives the text/plain alternative from the composer's HTML body, preserving
@@ -2501,6 +2507,7 @@ export function EmailComposer({
dragTimeoutRef.current = setTimeout(clearDragState, 150);
}, [clearDragState]);
+ const isShellDrag = useIsShellDrag();
const handleDragEnter = useCallback((e: React.DragEvent) => {
if (isShellDrag(e)) return;
e.preventDefault();
@@ -2509,21 +2516,21 @@ export function EmailComposer({
setIsDraggingOver(true);
resetDragTimeout();
}
- }, [resetDragTimeout]);
+ }, [isShellDrag, resetDragTimeout]);
const handleDragLeave = useCallback((e: React.DragEvent) => {
if (isShellDrag(e)) return;
e.preventDefault();
e.stopPropagation();
resetDragTimeout();
- }, [resetDragTimeout]);
+ }, [isShellDrag, resetDragTimeout]);
const handleDragOver = useCallback((e: React.DragEvent) => {
if (isShellDrag(e)) return;
e.preventDefault();
e.stopPropagation();
resetDragTimeout();
- }, [resetDragTimeout]);
+ }, [isShellDrag, resetDragTimeout]);
const handleDrop = useCallback((e: React.DragEvent) => {
if (isShellDrag(e)) return;
@@ -2533,7 +2540,7 @@ export function EmailComposer({
if (e.dataTransfer.files?.length) {
addFiles(Array.from(e.dataTransfer.files));
}
- }, [addFiles, clearDragState]);
+ }, [addFiles, clearDragState, isShellDrag]);
const removeAttachment = (index: number) => {
const att = attachments[index];
@@ -6044,6 +6051,7 @@ export function RecipientChipInput({
setDropIndex(past ? index + 1 : index);
};
+ const isShellDrag = useIsShellDrag();
// Insert the dragged chip at `target`. Same-field is a local reorder;
// cross-field routes through onMoveChip with the destination index (#593).
const performDrop = (e: React.DragEvent, target: number) => {