Skip to content

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

Open
bengotow wants to merge 3 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 3 commits into
masterfrom
claude/email-drag-attach-eml-jxfnwj

Conversation

@bengotow

@bengotow bengotow commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Summary

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.

Most of the machinery already existed. Thread rows publish their ids on mailspring-threads-data for the folder-drop feature, and the composer is already wrapped in a DropZone with a "Drop to Attach" overlay — the composer just had to learn to accept that type. GetMessageRFC2822Task + Actions.addAttachment are what "Forward as Attachment" already used to turn a message into a .eml and hang it off a draft.

The .eml is materialized on drop, not on dragstart: dragstart must populate dataTransfer synchronously, but fetching raw RFC2822 source is a round trip to the sync engine. Since the drop target is in-app, only thread ids are needed at dragstart.

Key Changes

  • Shared staging helpers (app/src/services/eml-utils.ts) — the "pick the thread's representative message → fetch raw source → write a temp file" sequence was copy-pasted between the message list and the thread list context menu. It now lives here and all four call sites share it:

    • newestExportableMessagesForThreadIds() — newest non-draft message per thread
    • stageMessagesAsEml() / stageMessageAsEml() — fetch and write to isolated temp .eml files
    • stageThreadsAsEml() / stageThreadAsEml() — thread ids straight through to staged files
    • discardStagedEml() — drop a staged file once the caller is done with it
  • Composer drop handling (composer-view.tsx) — accepts DragDropTypes.ThreadsDragType, reads the ids synchronously in _onDrop, then stages and attaches asynchronously.

  • Attachments area (attachments-area.tsx) — an attaching boolean renders a Spinner + "Attaching…" placeholder while files are being materialized.

  • Drag-type constants (app/src/drag-drop-types.ts) — mailspring-threads-data and mailspring-accounts= are no longer magic strings; the thread list that writes them and the composer and folder sidebar that read them share the constants.

  • Test coverage (app/spec/services/eml-utils-spec.ts) — the staging helpers, temp-directory cleanup, the discardStagedEml prefix guard, and the unavailable-vs-failed split. Suite is at 1489 passing.

Behavior changes beyond the new feature

  • Drafts are excluded when picking a thread's representative message. They only exist locally, so the sync engine has no raw source — previously an unsent draft could be selected as "newest message in thread" and the export would silently produce nothing. This fixes the existing Forward-as-Attachment and Save-as-.eml paths too.
  • Attachment filenames are unchanged for the existing features (Forwarded Message.eml); dragged-in messages are named after their subject.
  • Dropping a thread onto a reply composed within that same thread is allowed. An earlier commit filtered it out, but the payload isn't readable during dragEnter, so the drop cover had already appeared by the time the id was dropped — the drop looked accepted and then did nothing. Outlook and Gmail both allow it.

Implementation notes

  • Each message stages into its own randomly-named directory, so concurrent stages of the same message can't overwrite one another and the basename can be a clean display name.
  • All fetches are queued before any is awaited, so a multi-thread drop isn't serialized on the sync engine's round trips.
  • A GetMessageRFC2822Task can reach complete without having written anything, so results are filtered by file existence; those staging directories are removed immediately, and the rest are freed from addAttachment's onCreated, which fires after the attachment store has copied the file into its own directory.

Known limitation

This works for the inline composer (replying/forwarding in the main window). It does not work for popout composer windows — Chromium doesn't carry custom dataTransfer types across Electron BrowserWindow boundaries — and it doesn't enable drag-to-Desktop. Both need webContents.startDrag, which requires the file to exist before the drag begins, i.e. speculative pre-fetching on mousedown. That's a different mechanism and is left out rather than half-built.

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 commits address review with temp-directory cleanup, singular staging helpers, a shared DragDropTypes constants module, and clearer failure messaging.

  • Adds EmlUtils.newestExportableMessagesForThreadIds, plural (stageMessagesAsEml, stageThreadsAsEml) and singular (stageMessageAsEml, stageThreadAsEmlStagedEml | null) helpers, plus discardStagedEml. Staged messages get 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 report "conversation has no message that can be attached" separately from "could not download"; both cases are now collapsed into a single dialog with \n\n-joined lines rather than stacking modals.
  • New app/src/drag-drop-types.ts exports ThreadsDragType / AccountsDragTypePrefix / accountIdsForDragTypes; thread-list.tsx, sidebar-item.ts, and composer-view.tsx all reference the constants instead of hard-coded magic strings.
  • ComposerView's DropZone accepts thread drops, reads the payload synchronously, and stages asynchronously; dropping a thread onto a reply within the same thread is allowed (matches Gmail/Outlook).
  • AttachmentsArea takes a boolean attaching prop and renders the shared Spinner component + "Attaching…" (composer still tracks the count internally to know when the last drop lands); CSS aligns the placeholder with .nylas-attachment-item.
  • 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 invokes discardStagedEml from onCreated.
  • Adds jasmine specs for newestExportableMessagesForThreadIds, stageMessagesAsEml (including cleanup of missing-file dirs), stageMessageAsEml/stageThreadAsEml (null on failure/unavailable), discardStagedEml (including the prefix guard), and stageThreadsAsEml's unavailable-vs-failed split.

Issues

All clear! No issues remaining. 🎉

4 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)
  • 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. (fixed by commit b346a73)
  • 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 b346a73.

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

@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, stageMessagesAsEml / stageMessageAsEml (fetch raw RFC2822 via GetMessageRFC2822Task into isolated temp .eml files), stageThreadsAsEml / stageThreadAsEml (report threads with nothing exportable separately from fetch failures), and discardStagedEml (deletes a staged file and its temp dir, guarded to only remove mailspring-eml-* directories).
  • Composer accepts drops carrying the threads drag type: stages files, attaches them, shows a spinner placeholder while preparing, and cleans up each staged temp file from addAttachment's onCreated once it has been copied into the attachment store. Failure messaging distinguishes conversations with nothing to attach from downloads that failed.
  • New app/src/drag-drop-types.ts centralizes the custom dataTransfer type constants (ThreadsDragType, AccountsDragTypePrefix) and account-id parsing, replacing inline string literals across the thread list, account sidebar, and composer.
  • message-list.tsx and thread-list-context-menu.ts refactored to use the shared single-message helpers and to discard the staged file after attaching.
  • New spec coverage for the staging, discard, and thread-resolution helpers.

Issues

1 potential issue found:

  • The self-thread drop guard removed in aabcf6c is still absent after b346a73: _onThreadsReceived no longer filters id !== this.props.draft.threadId, so dropping the thread you're currently replying to onto its own draft attaches that conversation to itself — the accidental case the earlier commit 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 b346a73.

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.

@bengotow bengotow left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Some inline comments @claude

<RetinaImg name="inline-loading-spinner.gif" mode={RetinaImg.Mode.ContentPreserve} />
<span>
{attachingThreadCount === 1
? localized('Attaching message…')

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Can we collapse this into just Attaching... and remove the attachingThreadCount and rename the prop to attaching: boolean, I think that'd be cleaner and make this more portable to future use

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I think we do have to keep the state at the composer-view layer for tracking when we're done, just remove it from this component

))}
{attachingThreadCount > 0 && (
<div className="attaching-messages">
<RetinaImg name="inline-loading-spinner.gif" mode={RetinaImg.Mode.ContentPreserve} />

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

This image looks like shit, can you use the Spinner component? In a follow-up we'll clean up other remaining uses of this. The problem is it doesn't look good in dark mode and can't be inverted.

Comment on lines +327 to +329
// Accept drops of threads from the thread list, attaching each one as a
// .eml file. dataTransfer is only valid for the duration of this handler,
// so read the payload now and hand the ids off to an async worker.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

please remove this comment it's self explanatory

// type. Note that we can only look at `types` here — the payload itself is
// not readable until the drop actually happens.
_hasThreadsForDrop = (event: React.DragEvent<HTMLDivElement>) => {
return event.dataTransfer.types.includes('mailspring-threads-data');

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

can you make mailspring-threads-data a constant and import it?

: localized(
'%1$@ of the conversations have no message that can be attached.',
unavailableThreadIds.length
)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Can we make this say "one or more" so it's a single localized string, just to simplify the amount of localization required

Comment on lines +396 to +399
? localized('Could not download the original message. Please try again.')
: localized(
'Could not download %1$@ of the original messages. Please try again.',
failedCount

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

same with this one, let's make it a single error dialog by rephrasing it to say one or more

Comment on lines +171 to +172
const message = messages[0];
const pathModule = require('path');
const fs = require('fs');
const tempDir = pathModule.join(
require('@electron/remote').app.getPath('temp'),
`mailspring-fwd-${message.id}`
);
fs.mkdirSync(tempDir, { recursive: true });
const tempPath = pathModule.join(tempDir, 'Forwarded Message.eml');

const task = new GetMessageRFC2822Task({
messageId: message.id,
accountId: message.accountId,
filepath: tempPath,
const staged = await EmlUtils.stageMessagesAsEml([message], {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

can we write a wrapper stageMessageAsEml (singular) and do the array wrapping and unwrapping in there in the EmlUtils? It feels like this is awkward and we repeatedly reference staged[0] - i think at the very least it should be destructured right here so it's all colocated in these three lines

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

edit: i think we actually want stageThreadAsEml (signular)

Comment thread app/src/services/eml-utils.ts Outdated
const yes: T[] = [];
const no: T[] = [];
items.forEach((item) => (predicate(item) ? yes : no).push(item));
return [yes, no];

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I think we have either lodash or underscore, lets just use that


// Placeholder shown while a thread dragged onto the composer is being fetched
// from the sync engine and written out as a .eml file.
.attaching-messages {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

can you align this with the real attachment items better?

Image

- Use the Spinner component for the attaching placeholder instead of
  inline-loading-spinner.gif, which can't be inverted for dark mode.
- Collapse the placeholder to "Attaching…" and pass AttachmentsArea a
  plain `attaching` boolean. ComposerView keeps the count, since it still
  needs it to know when the last in-flight drop has landed.
- Line the placeholder up with .nylas-attachment-item, matching its left
  indent and font size.
- Add stageMessageAsEml and stageThreadAsEml, singular wrappers that
  return StagedEml | null, so callers staging one thing stop indexing
  into an array. thread-list-context-menu now goes straight from a thread
  id to a staged file and destructures it once.
- Collapse the two drop failure dialogs into one, phrased "one or more"
  so no count has to be interpolated and both cases can be reported
  together rather than stacking two dialogs.
- Move the 'mailspring-threads-data' and 'mailspring-accounts=' magic
  strings into DragDropTypes, shared by the thread list that writes them
  and the composer and folder sidebar that read them.
- Use underscore's partition instead of a hand-rolled one.
- Drop a comment that restated the code below it.

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

Copy link
Copy Markdown
Collaborator Author

Thanks @bengotow — all eleven addressed in b346a73, plus a PR description rewrite (it still described the self-drop filter that aabcf6c removed, which is what the bot kept flagging).

  • Spinner — swapped to the Spinner component. Scaled the dots down to 6px in the composer so it sits on one line with the label; it picks up @gray-light so it inverts properly.
  • attaching: boolean — done, and ComposerView keeps the count as you suggested, since it still needs it to know when the last in-flight drop has landed. The component just gets the boolean.
  • Alignment — matched .nylas-attachment-item's left indent (@spacing-standard) and @font-size-small, so it lines up with the cards above it.
  • stageMessageAsEml / stageThreadAsEml — added both singular wrappers returning StagedEml | null. The thread-list menu now goes from thread id straight to a staged file and destructures once, so the staged[0] repetition is gone.
  • Single error dialog — the two dialogs are now one, phrased "one or more" so no count is interpolated. This also fixes a case I'd left in: when both problems occurred the old code stacked two dialogs.
  • mailspring-threads-data constant — new DragDropTypes module. I also moved the producer (thread-list.tsx) and the folder sidebar onto it rather than leaving them hardcoded, and folded the mailspring-accounts= parsing the sidebar was doing inline into accountIdsForDragTypes.
  • partition — using underscore's.
  • Comment — removed.

One thing worth confirming: the drop is only wired to the inline composer. A drag into a popout composer window won't register — Chromium doesn't carry custom dataTransfer types across BrowserWindow boundaries — so that would need webContents.startDrag and a speculative pre-fetch. Happy to take it on separately if you want it.

Local suite: 1489 passing, 0 failing.


Generated by Claude Code

@bengotow

Copy link
Copy Markdown
Collaborator Author

@claude this is working great, but with one exception. You can drag and drop anywhere into the composer EXCEPT over the current draft text area (where the body text of the draft appears). If you drop into the composer header, it works, if you drop below the "quoted text expand/collapse button" it works, it's just the area right in the center that cannot be dropped on. Unfortunately this is the primary area people will try to drop, maybe it's triggering some inline file attachment code?

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