Skip to content

Add support for dragging threads into composer as .eml attachments - #2800

Open
bengotow wants to merge 2 commits into
masterfrom
claude/email-drag-attach-eml-jxfnwj
Open

Add support for dragging threads into composer as .eml attachments#2800
bengotow wants to merge 2 commits into
masterfrom
claude/email-drag-attach-eml-jxfnwj

Conversation

@bengotow

Copy link
Copy Markdown
Collaborator

Summary

This change enables users to drag threads from the thread list directly into the composer to attach them as .eml files. It introduces new utilities for exporting messages as RFC2822 .eml files and integrates them throughout the codebase.

Key Changes

  • New EML export utilities (app/src/services/eml-utils.ts):

    • newestExportableMessagesForThreadIds() - Resolves thread IDs to their most recent non-draft messages
    • stageMessagesAsEml() - Fetches raw RFC2822 source from sync engine and writes to temporary .eml files in isolated directories
    • stageThreadsAsEml() - Convenience wrapper combining the above two functions
    • Each message is staged in its own randomly-named subdirectory to prevent concurrent operations from interfering
  • Composer drag-and-drop support (app/internal_packages/composer/lib/composer-view.tsx):

    • Detects mailspring-threads-data MIME type in drag events
    • Accepts thread drops and extracts thread IDs from the drag payload
    • Filters out drops of threads onto themselves (prevents attaching a thread to itself)
    • Shows loading placeholder in attachments area while files are being fetched
    • Handles partial failures gracefully with user-facing error messages
  • Attachments area UI enhancement (app/internal_packages/composer/lib/attachments-area.tsx):

    • Added attachingThreadCount prop to display loading spinner and message count while threads are being staged
    • New .attaching-messages styling for the placeholder
  • Refactored existing forward-as-attachment flows:

    • app/internal_packages/thread-list/lib/thread-list-context-menu.ts - "Forward as Attachment" context menu now uses stageMessagesAsEml()
    • app/internal_packages/message-list/lib/message-list.tsx - Forward message action now uses stageMessagesAsEml()
    • Both now benefit from the centralized, tested implementation
  • Comprehensive test coverage (app/spec/services/eml-utils-spec.ts):

    • Tests for newestExportableMessagesForThreadIds() covering thread resolution, draft filtering, and edge cases
    • Tests for stageMessagesAsEml() covering file staging, concurrent operations, partial failures, and custom filenames
    • Tests verify that fetches are queued before awaiting (prevents serialization on sync engine)

Implementation Details

  • Messages are staged into isolated temporary directories using cryptographic tokens to prevent race conditions when the same message is staged concurrently
  • The sync engine fetch is awaited after all tasks are queued, enabling parallel remote operations
  • Partial failures are handled by checking for file existence after the fetch completes; missing files are silently omitted from results
  • Drafts are excluded from export since they only exist locally and the sync engine has no raw source for them
  • The implementation reuses the existing defaultEmlFilename() utility for generating clean, human-readable attachment names from message subjects

https://claude.ai/code/session_01TQrDtxTjqZV5ms7W7Jp91m

Dragging a thread out of the thread list and dropping it on an open
composer now attaches it as a .eml file, the way Outlook and Gmail do.

Thread rows already publish their ids on `mailspring-threads-data` for
the folder-drop feature, so the composer's DropZone just had to learn to
accept that type. The .eml itself is materialized on drop rather than on
dragstart: fetching the raw RFC2822 source is a round trip to the sync
engine, and `dragstart` has to populate dataTransfer synchronously.
While the fetch is in flight the attachments area shows a placeholder.

Dropping a thread onto a reply being composed inside that same thread is
ignored — attaching a conversation to itself isn't useful.

The staging logic (pick the thread's representative message, fetch it,
write it to a temp file) was already duplicated between "Forward as
Attachment" in the message list and the thread list context menu, so it
moves into EmlUtils alongside defaultEmlFilename and all four call sites
share it. Attachment names are unchanged for the existing features;
dragged-in messages are named after their subject.

Drafts are now excluded when picking a thread's representative message.
They only exist locally, so the sync engine has no raw source to return
for them — previously an unsent draft could be picked as the newest
message in its thread and the export would silently produce nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQrDtxTjqZV5ms7W7Jp91m
@indent-staging

indent-staging Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Warning

Indent Zero is shutting down on August 7th. Please migrate over to Indent 2.0 to continue getting PR reviews.

PR Summary

Enables dragging a thread from the thread list onto an open composer to attach it as a .eml file (matching Outlook/Gmail behavior), and consolidates the previously-duplicated "stage a message as .eml" logic used by "Forward as Attachment" and "Save as .eml..." into a shared EmlUtils module. Newest-message selection now excludes drafts across all four call sites, fixing a silent no-op when a thread's newest message was an unsent draft. Follow-up commit adds temp-directory cleanup and clearer failure messaging in response to review.

  • Adds EmlUtils.newestExportableMessagesForThreadIds, stageMessagesAsEml, stageThreadsAsEml, and discardStagedEml; each staged message gets a randomly-named mailspring-eml-* directory under os.tmpdir(), missing files are cleaned up immediately, and callers free the rest from addAttachment's onCreated.
  • stageThreadsAsEml returns { staged, unavailableThreadIds } so the composer can show distinct dialogs for "conversation has no message that can be attached" vs "could not download; please try again" (with counts that no longer conflate the two).
  • Teaches ComposerView's DropZone to accept the existing mailspring-threads-data MIME published by thread-list.tsx; drop handler reads the payload synchronously and stages the threads asynchronously. Dropping a thread onto a reply within that same thread is now allowed (matches Gmail/Outlook).
  • AttachmentsArea gains an optional attachingThreadCount prop that renders a spinner + "Attaching…" placeholder while .eml files are being materialized.
  • Rewrites "Forward as Attachment" in message-list.tsx and thread-list-context-menu.ts, and the single/bulk "Save as .eml..." branches, to call the shared helpers; each attachment site now invokes discardStagedEml from onCreated.
  • Adds jasmine specs for newestExportableMessagesForThreadIds, stageMessagesAsEml (including cleanup of missing-file dirs), discardStagedEml (including the prefix guard that refuses non-mailspring-eml-* dirs), and stageThreadsAsEml's unavailable-vs-failed split.

Issues

1 potential issue found:

  • The PR description still lists "Filters out drops of threads onto themselves (prevents attaching a thread to itself)" as a key change, but commit aabcf6c intentionally removed that filter (matching Gmail/Outlook, per the commit message). Update the PR description to reflect the current behavior. → Autofix
3 issues already resolved
  • Dropping a thread whose newest message is a draft surfaces "Could not download the original message. Please try again." even though no fetch was attempted — stageThreadsAsEml returns [] because newestExportableMessagesForThreadIds filters drafts, and _onThreadsReceived's staged.length < threadIds.length guard treats that as a network failure. (fixed by commit aabcf6c)
  • Dragging a thread onto a reply composer that lives inside that same thread shows the "Drop to attach" overlay and then silently does nothing — _shouldAcceptDrop accepts the drop, but _onThreadsReceived filters draft.threadId out and exits without user feedback, so the drop appears to succeed but never produces an attachment. (fixed by commit aabcf6c)
  • stageMessagesAsEml creates a fresh mailspring-eml-<id>-<token> directory under os.tmpdir() for every staged message and never cleans it up — successful, failed, and no-file-written outcomes all leak the directory, so temp dirs accumulate over the app's lifetime for every forward, drag-attach, and thread-list export. (fixed by commit aabcf6c)

CI Checks

All CI checks passed for commit aabcf6c.

Custom Rules 3 rules evaluated, 3 passed, 0 failed

Passing This is a longer title to see what happens when they are too long to fit
Passing B
Passing Ben Rule

View all rules


⚡ Autofix All Issues

@indent

indent Bot commented Aug 14, 2026

Copy link
Copy Markdown
PR Summary

Adds the ability to drag threads from the thread list directly into the composer, attaching each one as a .eml file, and consolidates the existing "Forward as Attachment" / "Save as .eml" staging logic into shared EmlUtils helpers.

  • New EmlUtils helpers (app/src/services/eml-utils.ts): newestExportableMessagesForThreadIds (newest non-draft message per thread), stageMessagesAsEml (fetch raw RFC2822 via GetMessageRFC2822Task into isolated temp .eml files), stageThreadsAsEml (returns { staged, unavailableThreadIds }), and discardStagedEml (deletes a staged file and its temp dir, guarded to only remove mailspring-eml-* directories).
  • Composer accepts drops carrying the mailspring-threads-data MIME type: parses thread ids, stages files, attaches them, shows a loading placeholder, and cleans up each staged temp file from addAttachment's onCreated once it has been copied into the attachment store.
  • Failure messaging distinguishes threads with nothing exportable ("This conversation has no message that can be attached.") from genuine download failures (the retryable "Could not download…" dialog).
  • message-list.tsx and thread-list-context-menu.ts refactored to use the shared helpers (which now exclude drafts via draft: false) and to discard the staged file after attaching.
  • New spec coverage for newestExportableMessagesForThreadIds, stageMessagesAsEml, discardStagedEml, and stageThreadsAsEml.

Issues

1 potential issue found:

  • Commit aabcf6c removed the threadIds.filter(id => id !== this.props.draft.threadId) guard from _onThreadsReceived, so dropping the thread you're currently replying to onto its own draft now attaches that conversation to itself — the accidental case the earlier commit intentionally prevented and the PR description still advertises. Confirm whether this removal was intended.

Select any checkbox above to have Indent auto-fix the issue

1 issue already resolved
  • Dragging a thread that has no server-backed message (e.g. a draft-only thread from the Drafts folder) onto the composer shows "Could not download the original message. Please try again.", which is misleading and non-retryable since there is nothing to download. (fixed by commit aabcf6c)

CI Checks

All CI checks passed on aabcf6c.

Bulk Actions
  • Autofix all issues

Comment thread app/internal_packages/composer/lib/composer-view.tsx Outdated
Comment thread app/internal_packages/composer/lib/composer-view.tsx Outdated
Comment thread app/internal_packages/composer/lib/composer-view.tsx Outdated
Comment thread app/src/services/eml-utils.ts Outdated
Three fixes from PR review on the drag-to-attach change.

Staged .eml files are now cleaned up. Staging moved from the old
`mailspring-fwd-${message.id}` path — bounded per message, so a repeat
forward overwrote it — to a randomly named directory, which grows without
limit. stageMessagesAsEml now removes the directories of messages whose
file never arrived, and discardStagedEml lets callers drop the rest once
they're done; the attachment store copies the file into its own directory
before addAttachment's onCreated fires, so that's the point where the
staged copy becomes garbage. It refuses any directory not named
`mailspring-eml-*`, so a stray path can't take a real directory with it.

A thread with nothing exportable in it is no longer reported as a failed
download. stageThreadsAsEml separates the two: a conversation holding
only unsent drafts is never fetched at all, so "Please try again" was
both wrong and unactionable. They now get distinct messages, and the
failure count no longer includes threads that were never fetched.

Dropping a thread onto a reply composed within that same thread is now
allowed. The payload isn't readable during dragEnter, so the drop cover
had already appeared by the time the id was filtered out — the drop
looked accepted and then did nothing. Outlook and Gmail both allow it,
and the guard was speculative.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQrDtxTjqZV5ms7W7Jp91m

Copy link
Copy Markdown
Collaborator Author

All three review findings addressed in aabcf6c.

Temp directory leak — valid, and a regression this PR introduced. The old path was mailspring-fwd-${message.id}, bounded per message so a repeat forward overwrote it; the random token removed that bound. stageMessagesAsEml now removes the directories of messages whose file never arrived, and a new discardStagedEml lets callers drop the rest. addAttachment awaits _copyToInternalPath before firing onCreated (attachment-store.ts:459-467), so that callback is the safe point to delete the staged copy — all three staging call sites now do. The helper refuses any directory not named mailspring-eml-*.

Misleading download-failed dialog — valid. stageThreadsAsEml now returns { staged, unavailableThreadIds }, so a conversation holding only unsent drafts (never fetched) is reported separately from a fetch that was attempted and failed, and the failure count no longer includes threads that were never fetched.

Same-thread drop swallowed silently — valid, but resolved by removing the guard rather than by adding a notice. The payload isn't readable during dragEnter, so shouldAcceptDrop can't inspect ids and the drop cover has already appeared by the time the id is filtered — the drop looks accepted and then does nothing. Since Outlook and Gmail both allow it and attaching the message you're replying to is harmless, allowing it removes the dead end instead of explaining it.

Spec suite: 1486 passing, 0 failing (up from 1480 — 6 new specs covering staging cleanup, the discard guard, and the unavailable-vs-failed split).


Generated by Claude Code

} catch (err) {
return;
}
if (!threadIds.length) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Failing Functional · Self-thread drop guard removed

The self-drop guard that was here in the first commit (threadIds = threadIds.filter((id) => id !== this.props.draft.threadId);) was removed in aabcf6c and not replaced. For a reply draft, draft.threadId is set, so dragging that same thread onto the composer now stages its newest non-draft message and attaches the conversation to itself — the "almost always an accident" case the earlier commit deliberately suppressed, and which the PR description still lists ("Filters out drops of threads onto themselves"). If this removal was intentional, update the description; otherwise restore the filter. New-compose drafts have a null threadId, so they're unaffected either way.

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