Skip to content

fix: thread move/delete act on the whole conversation and surface failures#302

Open
Monkey7539 wants to merge 1 commit into
maathimself:mainfrom
Monkey7539:fix/thread-move-partial
Open

fix: thread move/delete act on the whole conversation and surface failures#302
Monkey7539 wants to merge 1 commit into
maathimself:mainfrom
Monkey7539:fix/thread-move-partial

Conversation

@Monkey7539

Copy link
Copy Markdown
Contributor

Problem

With group by thread enabled:

  1. Thread move looks like it worked but doesn't (or only partially). The single-thread moveTo handler calls api.bulkMove(...) and never inspects the response. /messages/bulk-move returns 200 with a per-message moved list, and messages can legitimately be missing from it: the destination folder is verified per account with an exact path match, and IMAP move failures are only logged server-side. The row disappears optimistically, no error shows, and the unmoved messages come back on the next sync.

  2. Cross-account threads guarantee silent skips. GET /thread/:threadId deliberately spans all of the user's accounts (and includes Sent copies). A thread woven from two accounts moved into an account-specific folder produces server logs like:

    bulk-move: folder "Union Sports Arena/Staff & HR" not found for account a3078f0c…, skipping
    

    while the client shows success. (Real logs from my instance — this is how I hit it.)

  3. Checkbox bulk move/delete on thread rows only acts on each thread's visible (newest) message. The single-row handlers resolve the whole conversation via resolveMessagesForThreadAction; the multi-select paths don't, so the rest of the thread stays behind.

Thread delete was also affected by (3), and cross-account threads surface real per-message failures (that path already checks result.deleted) — e.g. when one account's IMAP connection is unhealthy.

Fix (frontend only, MessageList.jsx)

  • moveTo (single thread): check result.moved; on partial failure restore the visible row and show the existing bulk-move error toast (reuses messageList.bulkMoved.failBody — no new locale keys).
  • Thread moves are scoped to the acting message's account (single-row move to the right-clicked message's account; multi-select expansion scoped per row's account). Folder paths are account-specific, so the other account's copies were never movable — scoping makes the action do exactly what it can, with nothing silently dropped. Delete keeps its delete-everywhere semantics.
  • handleBulkMove / handleBulkDelete: expand selected thread rows via resolveMessagesForThreadAction before calling the API; undo and the unmount/beforeunload flush paths carry the full id set; failure toasts count actual failed messages.

Testing

  • npm test (frontend): 1394 pass
  • npm run lint: clean (--max-warnings 0)
  • npm run build: clean
  • Manually traced against the server behavior above; the reproduction came from a live instance with a two-account thread and one account with a chronically timing-out IMAP pool.

Written with AI assistance (Claude); I have reviewed and tested the changes and am authorized to submit them under the project's CLA.

🤖 Generated with Claude Code

…lures

Three related gaps in threaded-view actions:

1. Single-thread move ignored the bulk-move response. The server returns 200
   with a per-message `moved` list; messages skipped (destination folder
   missing on that account) or whose IMAP move failed were silently dropped —
   the row disappeared optimistically and reappeared on the next sync with no
   error. Now partial failures restore the row and show the bulk-move error
   toast, mirroring the checkbox bulk handler.

2. Thread moves are scoped to the acting message's account. A thread can span
   accounts (and always includes Sent copies); the destination folder path is
   account-specific, so the other account's copies were guaranteed server-side
   skips. Scoping makes the move do exactly what it can do, with nothing
   silently dropped.

3. Checkbox bulk move/delete on thread rows only acted on each thread's
   visible (newest) message — the rest of the conversation stayed behind.
   Both handlers now expand selected thread rows via
   resolveMessagesForThreadAction (move expansion scoped per account as
   above; delete keeps its delete-everywhere semantics), and the undo /
   unmount-flush paths carry the full id set.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@maathimself

Copy link
Copy Markdown
Owner

Thanks for this. The underlying problems are real: bulk delete only removed each thread's visible message, and the single-row move swallowed partial failures. I want both fixed. But it can't merge as-is because of how it selects the messages to act on.

Blocker: destructive actions are keyed on thread_id, which over-groups. resolveMessagesForThreadAction expands a row with api.getThread(message.thread_id), and getThread selects on thread_key = COALESCE(thread_id, id). The issue is that thread_id is not a reliable conversation key here: messages without References/In-Reply-To headers fall back to grouping by normalized subject within a 90-day window, which lumps unrelated mail together. In my data that produces threads of 51 to 689 messages (batches of identical automated notifications that share a subject). Deleting or moving such a row would sweep hundreds of unrelated messages, and this PR extends that from single-row to bulk multi-select, so selecting N rows can delete N times that.

The codebase already has the safe primitive for exactly this: gatherSnoozeConversation in mail.js (added for the snooze fix) walks the RFC 5322 reply chain and takes the connected component, deliberately avoiding thread_id for destructive ops. Please expand via that reply-chain approach rather than getThread / thread_key.

Also, high severity: the move path is not chunked. handleBulkDelete chunks at the server's 500-id cap, but handleBulkMove and the single-row move send the whole set to api.bulkMove in one call. An over-grouped or simply large thread (or a multi-select summing over 500) will 400 and the entire move fails. It needs the same 500 chunking as delete.

Tests. This is destructive, multi-account, partial-failure logic with no coverage. The snooze reply-chain path is unit-tested (mail.snooze.test.js); please add tests for the expansion unit and the partial-failure restore here as well.

The failure-surfacing improvements are the right direction. Once the expansion goes through the reply chain and the move is chunked, I am happy to re-review.

@maathimself

Copy link
Copy Markdown
Owner

One clarification on where this fix should live. resolveMessagesForThreadAction is the shared expansion helper, and single-row delete and move already route through it, so they already have this thread_id-based behavior on main. If you move the expansion to the reply-chain connected component inside that helper, you fix the single-row paths and the new bulk paths in one place. Please do it there rather than only at the bulk call sites.

Heads-up that the same helper also feeds the non-destructive read/star actions. Switching it to the reply chain changes those too. That is arguably more correct, but it is a behavior change, so either scope the reply-chain expansion to the destructive delete/move paths specifically, or make the read/star change deliberately.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants