diff --git a/app/internal_packages/account-sidebar/lib/sidebar-item.ts b/app/internal_packages/account-sidebar/lib/sidebar-item.ts index ef7ad0adff..eca28a26cb 100644 --- a/app/internal_packages/account-sidebar/lib/sidebar-item.ts +++ b/app/internal_packages/account-sidebar/lib/sidebar-item.ts @@ -12,6 +12,7 @@ import { CategoryStore, Actions, RegExpUtils, + DragDropTypes, localized, TaskQueue, } from 'mailspring-exports'; @@ -281,7 +282,7 @@ export default class SidebarItem { onCollapseToggled: toggleItemCollapsed, onDrop(item, event) { - const jsonString = event.dataTransfer.getData('mailspring-threads-data'); + const jsonString = event.dataTransfer.getData(DragDropTypes.ThreadsDragType); let jsonData = null; try { jsonData = JSON.parse(jsonString); @@ -297,7 +298,7 @@ export default class SidebarItem { shouldAcceptDrop(item, event) { const target = item.perspective; const current = FocusedPerspectiveStore.current(); - if (!event.dataTransfer.types.includes('mailspring-threads-data')) { + if (!event.dataTransfer.types.includes(DragDropTypes.ThreadsDragType)) { return false; } if (target.isEqual(current)) { @@ -306,10 +307,7 @@ export default class SidebarItem { // We can't inspect the drag payload until drop, so we use a dataTransfer // type to encode the account IDs of threads currently being dragged. - const accountsType = event.dataTransfer.types.find((t) => - t.startsWith('mailspring-accounts=') - ); - const accountIds = (accountsType || '').replace('mailspring-accounts=', '').split(','); + const accountIds = DragDropTypes.accountIdsForDragTypes(event.dataTransfer.types); return target.canReceiveThreadsFromAccountIds(accountIds); }, diff --git a/app/internal_packages/composer/lib/attachments-area.tsx b/app/internal_packages/composer/lib/attachments-area.tsx index 84f803c3e1..c5646e4e37 100644 --- a/app/internal_packages/composer/lib/attachments-area.tsx +++ b/app/internal_packages/composer/lib/attachments-area.tsx @@ -1,8 +1,14 @@ import React from 'react'; -import { Actions, AttachmentStore, Message } from 'mailspring-exports'; -import { AttachmentItem } from 'mailspring-component-kit'; +import { localized, Actions, AttachmentStore, Message } from 'mailspring-exports'; +import { AttachmentItem, Spinner } from 'mailspring-component-kit'; -export const AttachmentsArea: React.FunctionComponent<{ draft: Message }> = (props) => { +export const AttachmentsArea: React.FunctionComponent<{ + draft: Message; + // True while files dropped on the composer are still being prepared. They + // aren't on the draft yet, so a placeholder stands in to show the drop was + // accepted. + attaching?: boolean; +}> = (props) => { const { files, headerMessageId } = props.draft; return ( @@ -20,6 +26,12 @@ export const AttachmentsArea: React.FunctionComponent<{ draft: Message }> = (pro onRemoveAttachment={() => Actions.removeAttachment(headerMessageId, file)} /> ))} + {props.attaching && ( +
+ + {localized('Attaching…')} +
+ )} ); }; diff --git a/app/internal_packages/composer/lib/composer-view.tsx b/app/internal_packages/composer/lib/composer-view.tsx index b2e9524b97..d01e831519 100644 --- a/app/internal_packages/composer/lib/composer-view.tsx +++ b/app/internal_packages/composer/lib/composer-view.tsx @@ -7,6 +7,8 @@ import { DraftStore, DraftEditingSession, MessageWithEditorState, + DragDropTypes, + EmlUtils, File, } from 'mailspring-exports'; import { webUtils } from 'electron'; @@ -41,6 +43,7 @@ interface ComposerViewState { quotedTextHidden: boolean; quotedTextPresent: boolean; isDropping: boolean; + attachingThreadCount: number; } // The ComposerView is a unique React component because it (currently) is a // singleton. Normally, the React way to do things would be to re-render the @@ -78,6 +81,7 @@ export default class ComposerView extends React.Component )} - + 0} />
) => { + return event.dataTransfer.types.includes(DragDropTypes.ThreadsDragType); }; _nonNativeFilePathForDrop = (event: React.DragEvent) => { @@ -313,6 +321,74 @@ export default class ComposerView extends React.Component { + let threadIds: string[] = []; + try { + threadIds = JSON.parse(json).threadIds || []; + } catch (err) { + return; + } + if (!threadIds.length) { + return; + } + + // Fetching the raw message from the sync engine is a remote round trip, so + // show a placeholder in the attachments area until the files land. + const dropCount = threadIds.length; + this.setState((state) => ({ attachingThreadCount: state.attachingThreadCount + dropCount })); + + let staged: Array<{ filePath: string }> = []; + let unavailableThreadIds: string[] = []; + try { + ({ staged, unavailableThreadIds } = await EmlUtils.stageThreadsAsEml(threadIds)); + } catch (err) { + AppEnv.reportError(err); + } finally { + if (this._mounted) { + this.setState((state) => ({ + attachingThreadCount: state.attachingThreadCount - dropCount, + })); + } + } + + if (!this._mounted) { + return; + } + for (const { filePath } of staged) { + Actions.addAttachment({ + filePath, + headerMessageId: this.props.draft.headerMessageId, + // The attachment store copies the file into its own directory before + // this fires, so the staged copy is free to go. + onCreated: () => EmlUtils.discardStagedEml(filePath), + }); + } + + // A thread with nothing exportable in it and a fetch that failed are + // different problems, and only the second one is worth retrying. + const problems = []; + if (unavailableThreadIds.length) { + problems.push( + localized('One or more of the conversations have no message that can be attached.') + ); + } + if (staged.length < threadIds.length - unavailableThreadIds.length) { + problems.push( + localized('One or more of the original messages could not be downloaded. Please try again.') + ); + } + if (problems.length) { + AppEnv.showErrorDialog(problems.join('\n\n')); + } }; _onFileReceived = (filePath: string) => { diff --git a/app/internal_packages/composer/styles/composer.less b/app/internal_packages/composer/styles/composer.less index baffeba94c..cb2883196f 100644 --- a/app/internal_packages/composer/styles/composer.less +++ b/app/internal_packages/composer/styles/composer.less @@ -600,6 +600,29 @@ body.platform-win32 { } } +// Placeholder shown while a thread dragged onto the composer is being fetched +// from the sync engine and written out as a .eml file. Indented to line up +// with .nylas-attachment-item, which it sits alongside. +.attaching-messages { + display: flex; + align-items: center; + margin: 0 0 @spacing-standard @spacing-standard; + color: @text-color-subtle; + font-size: @font-size-small; + + .spinner { + width: auto; + margin-right: @spacing-half; + + > div { + width: 6px; + height: 6px; + margin-right: 2px; + margin-left: 0; + } + } +} + // Overrides for the full-window popout composer .composer-full-window { width: 100%; diff --git a/app/internal_packages/message-list/lib/message-list.tsx b/app/internal_packages/message-list/lib/message-list.tsx index 05fbf007a5..0a9ce33148 100644 --- a/app/internal_packages/message-list/lib/message-list.tsx +++ b/app/internal_packages/message-list/lib/message-list.tsx @@ -150,29 +150,12 @@ class MessageList extends React.Component, MessageListSt if (!message || !this.state.currentThread) { return; } - const pathModule = require('path'); - const fs = require('fs'); - - // Use a unique subdirectory per operation so concurrent forwards don't - // race on the same file. The basename stays "Forwarded Message.eml" so - // the attachment has a clean display name. - 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.stageMessageAsEml(message, { + filename: 'Forwarded Message.eml', }); - Actions.queueTask(task); - await TaskQueue.waitForPerformRemote(task); - // Verify the file was actually written before creating a draft - if (!fs.existsSync(tempPath)) { + // The fetch is remote and can fail without writing anything + if (!staged) { AppEnv.showErrorDialog( localized('Could not download the original message. Please try again.') ); @@ -191,9 +174,10 @@ class MessageList extends React.Component, MessageListSt await TaskQueue.waitForPerformLocal(syncTask); Actions.addAttachment({ - filePath: tempPath, + filePath: staged.filePath, headerMessageId: draft.headerMessageId, onCreated: () => { + EmlUtils.discardStagedEml(staged.filePath); Actions.composePopoutDraft(draft.headerMessageId); }, }); diff --git a/app/internal_packages/thread-list/lib/thread-list-context-menu.ts b/app/internal_packages/thread-list/lib/thread-list-context-menu.ts index 261e47f6ff..caf3aeefb1 100644 --- a/app/internal_packages/thread-list/lib/thread-list-context-menu.ts +++ b/app/internal_packages/thread-list/lib/thread-list-context-menu.ts @@ -165,35 +165,17 @@ export default class ThreadListContextMenu { label: localized('Forward as Attachment'), click: async () => { const thread = this.threads[0]; - const messages = await DatabaseStore.findAll(Message, { threadId: thread.id }) - .order(Message.attributes.date.descending()) - .limit(1); - if (!messages.length) return; - - 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.stageThreadAsEml(thread.id, { + filename: 'Forwarded Message.eml', }); - Actions.queueTask(task); - await TaskQueue.waitForPerformRemote(task); - if (!fs.existsSync(tempPath)) { + if (!staged) { AppEnv.showErrorDialog( localized('Could not download the original message. Please try again.') ); return; } + const { message, filePath } = staged; const account = AccountStore.accountForId(message.accountId); const draft = await DraftFactory.createDraft({ @@ -207,9 +189,10 @@ export default class ThreadListContextMenu { await TaskQueue.waitForPerformLocal(syncTask); Actions.addAttachment({ - filePath: tempPath, + filePath, headerMessageId: draft.headerMessageId, onCreated: () => { + EmlUtils.discardStagedEml(filePath); Actions.composePopoutDraft(draft.headerMessageId); }, }); @@ -340,9 +323,7 @@ export default class ThreadListContextMenu { click: async () => { if (this.threadIds.length === 1) { const thread = this.threads[0]; - const messages = await DatabaseStore.findAll(Message, { threadId: thread.id }) - .order(Message.attributes.date.descending()) - .limit(1); + const messages = await EmlUtils.newestExportableMessagesForThreadIds([thread.id]); if (!messages.length) return; const message = messages[0]; @@ -369,15 +350,11 @@ export default class ThreadListContextMenu { const outputDir = selected[0]; const path = require('path'); - for (const thread of this.threads) { - const messages = await DatabaseStore.findAll(Message, { - threadId: thread.id, - }) - .order(Message.attributes.date.descending()) - .limit(1); - if (!messages.length) continue; + const messages = await EmlUtils.newestExportableMessagesForThreadIds( + this.threads.map((t) => t.id) + ); - const message = messages[0]; + for (const message of messages) { const filename = EmlUtils.defaultEmlFilename(message.subject); const task = new GetMessageRFC2822Task({ diff --git a/app/internal_packages/thread-list/lib/thread-list.tsx b/app/internal_packages/thread-list/lib/thread-list.tsx index b64a92339b..3882d35f30 100644 --- a/app/internal_packages/thread-list/lib/thread-list.tsx +++ b/app/internal_packages/thread-list/lib/thread-list.tsx @@ -14,6 +14,7 @@ import { Actions, Utils, CanvasUtils, + DragDropTypes, ChangeStarredTask, ChangeFolderTask, ChangeLabelsTask, @@ -242,8 +243,11 @@ class ThreadList extends React.Component< const canvas = CanvasUtils.canvasForDragging('threads', data.threadIds.length); event.dataTransfer.setDragImage(canvas, 10, 10); - event.dataTransfer.setData('mailspring-threads-data', JSON.stringify(data)); - event.dataTransfer.setData(`mailspring-accounts=${data.accountIds.join(',')}`, '1'); + event.dataTransfer.setData(DragDropTypes.ThreadsDragType, JSON.stringify(data)); + event.dataTransfer.setData( + `${DragDropTypes.AccountsDragTypePrefix}${data.accountIds.join(',')}`, + '1' + ); }; _onDragEnd = (event: React.DragEvent) => {}; diff --git a/app/spec/components/drop-zone-spec.tsx b/app/spec/components/drop-zone-spec.tsx new file mode 100644 index 0000000000..f8bc65fa1b --- /dev/null +++ b/app/spec/components/drop-zone-spec.tsx @@ -0,0 +1,83 @@ +import React from 'react'; +import { findRenderedDOMComponentWithClass, Simulate } from 'react-dom/test-utils'; + +import { DropZone } from '../../src/components/drop-zone'; +import MTestUtils from '../mailspring-test-utils'; + +// A drop target only receives a drop event if something calls preventDefault on +// dragover. The composer wraps a Slate editor, and drags that begin inside Slate +// are Slate's to manage, so DropZone steps aside for those — but stepping aside +// for every drag over the editor left the middle of the composer, the largest +// and most obvious drop target, silently refusing drops. +const dragEventData = (types: string[]) => ({ + dataTransfer: { + types, + effectAllowed: 'copy', + dropEffect: 'none', + getData: () => '', + }, +}); + +const renderZone = (shouldAcceptDrop: (e: any) => boolean) => + MTestUtils.renderIntoDocument( + {}} + onDragStateChange={() => {}} + > +
+ body +
+
footer
+
+ ) as any; + +describe('DropZone', function dropZone() { + describe('dragging over the Slate editor', () => { + it('allows the drop when the zone accepts the drag', () => { + const zone = renderZone(() => true); + const target = findRenderedDOMComponentWithClass(zone, 'inside-editor'); + const event = dragEventData(['mailspring-threads-data']); + + Simulate.dragOver(target, event as any); + + // preventDefault is what makes the browser deliver a drop here at all + expect(event.dataTransfer.dropEffect).toEqual('copy'); + }); + + it('leaves the drag alone when the zone does not accept it', () => { + const zone = renderZone(() => false); + const target = findRenderedDOMComponentWithClass(zone, 'inside-editor'); + const event = dragEventData(['application/x-slate-fragment']); + + Simulate.dragOver(target, event as any); + + // Untouched: Slate sets its own drop effect and draws the caret + expect(event.dataTransfer.dropEffect).toEqual('none'); + }); + }); + + describe('dragging elsewhere in the zone', () => { + it('allows the drop without consulting the editor exception', () => { + const zone = renderZone(() => false); + const target = findRenderedDOMComponentWithClass(zone, 'outside-editor'); + const event = dragEventData(['Files']); + + Simulate.dragOver(target, event as any); + + expect(event.dataTransfer.dropEffect).toEqual('copy'); + }); + + it('falls back to copy when effectAllowed is uninitialized', () => { + const zone = renderZone(() => true); + const target = findRenderedDOMComponentWithClass(zone, 'outside-editor'); + const event = dragEventData(['Files']); + event.dataTransfer.effectAllowed = 'uninitialized'; + + Simulate.dragOver(target, event as any); + + expect(event.dataTransfer.dropEffect).toEqual('copy'); + }); + }); +}); diff --git a/app/spec/services/eml-utils-spec.ts b/app/spec/services/eml-utils-spec.ts index cfe3cf1e09..d9a52ceb2e 100644 --- a/app/spec/services/eml-utils-spec.ts +++ b/app/spec/services/eml-utils-spec.ts @@ -1,4 +1,22 @@ -import { defaultEmlFilename } from '../../src/services/eml-utils'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { + Actions, + DatabaseStore, + Message, + TaskQueue, + GetMessageRFC2822Task, +} from 'mailspring-exports'; +import { + defaultEmlFilename, + discardStagedEml, + newestExportableMessagesForThreadIds, + stageMessageAsEml, + stageMessagesAsEml, + stageThreadAsEml, + stageThreadsAsEml, +} from '../../src/services/eml-utils'; describe('defaultEmlFilename', function () { describe('normal subjects', () => { @@ -115,9 +133,7 @@ describe('defaultEmlFilename', function () { it('strips all C0 control characters', () => { // Build a string with chars 0x01–0x1F between "a" and "b" - const controls = Array.from({ length: 31 }, (_, i) => - String.fromCharCode(i + 1) - ).join(''); + const controls = Array.from({ length: 31 }, (_, i) => String.fromCharCode(i + 1)).join(''); expect(defaultEmlFilename(`a${controls}b`)).toEqual('ab.eml'); }); }); @@ -148,3 +164,268 @@ describe('defaultEmlFilename', function () { }); }); }); + +describe('newestExportableMessagesForThreadIds', function () { + const queryFor = (results: Message[]) => { + const query: any = { + order() { + return this; + }, + limit() { + return this; + }, + then(callback) { + return Promise.resolve(results).then(callback); + }, + }; + return query; + }; + + it('returns the single newest message the query yields for each thread', async () => { + const a = new Message({ id: 'a', threadId: 't1' }); + const b = new Message({ id: 'b', threadId: 't2' }); + spyOn(DatabaseStore, 'findAll').andCallFake((klass, where) => + queryFor(where.threadId === 't1' ? [a] : [b]) + ); + + const messages = await newestExportableMessagesForThreadIds(['t1', 't2']); + expect(messages.map((m) => m.id)).toEqual(['a', 'b']); + }); + + it('excludes drafts, which have no raw source on the server', async () => { + spyOn(DatabaseStore, 'findAll').andCallFake(() => queryFor([])); + await newestExportableMessagesForThreadIds(['t1']); + expect((DatabaseStore.findAll as any).calls[0].args[1]).toEqual({ + threadId: 't1', + draft: false, + }); + }); + + it('omits threads that have no exportable message', async () => { + const a = new Message({ id: 'a', threadId: 't1' }); + spyOn(DatabaseStore, 'findAll').andCallFake((klass, where) => + queryFor(where.threadId === 't1' ? [a] : []) + ); + + const messages = await newestExportableMessagesForThreadIds(['t1', 't2']); + expect(messages.map((m) => m.id)).toEqual(['a']); + }); + + it('returns an empty array when given no threads', async () => { + spyOn(DatabaseStore, 'findAll').andCallFake(() => queryFor([])); + expect(await newestExportableMessagesForThreadIds([])).toEqual([]); + expect(DatabaseStore.findAll).not.toHaveBeenCalled(); + }); +}); + +describe('stageMessagesAsEml', function () { + let queued: GetMessageRFC2822Task[] = []; + let cleanup: string[] = []; + + // Stand in for the sync engine: when a fetch is awaited, write the file it + // was asked to produce. `writeFor` decides which ones actually get written. + const engineWrites = (writeFor: (task: GetMessageRFC2822Task) => boolean) => { + (TaskQueue.waitForPerformRemote as any).andCallFake((task: GetMessageRFC2822Task) => { + if (writeFor(task)) { + fs.writeFileSync(task.filepath, 'Subject: raw\r\n\r\nbody\r\n'); + cleanup.push(path.dirname(task.filepath)); + } + return Promise.resolve(); + }); + }; + + beforeEach(() => { + queued = []; + cleanup = []; + spyOn(Actions, 'queueTask').andCallFake((task) => queued.push(task)); + spyOn(TaskQueue, 'waitForPerformRemote').andCallFake(() => Promise.resolve()); + }); + + afterEach(() => { + for (const dir of cleanup) { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it('queues one fetch per message, staging each into its own directory', async () => { + engineWrites(() => true); + const messages = [ + new Message({ id: 'm1', accountId: 'a1', subject: 'Hello' }), + new Message({ id: 'm2', accountId: 'a2', subject: 'World' }), + ]; + + const staged = await stageMessagesAsEml(messages); + + expect(queued.length).toEqual(2); + expect(queued.map((t) => t.messageId)).toEqual(['m1', 'm2']); + expect(queued.map((t) => t.accountId)).toEqual(['a1', 'a2']); + expect(staged.map((s) => path.basename(s.filePath))).toEqual(['Hello.eml', 'World.eml']); + expect(path.dirname(staged[0].filePath)).not.toEqual(path.dirname(staged[1].filePath)); + }); + + it('queues every fetch before awaiting any of them', async () => { + let resolveAll; + const gate = new Promise((resolve) => (resolveAll = resolve)); + (TaskQueue.waitForPerformRemote as any).andCallFake(() => gate); + + const promise = stageMessagesAsEml([ + new Message({ id: 'm1', accountId: 'a1', subject: 'One' }), + new Message({ id: 'm2', accountId: 'a1', subject: 'Two' }), + ]); + expect(queued.length).toEqual(2); + resolveAll(); + const staged = await promise; + // Nothing was written, so nothing is reported as staged + expect(staged).toEqual([]); + }); + + it('omits messages whose file was never written', async () => { + engineWrites((task) => task.messageId === 'm1'); + const staged = await stageMessagesAsEml([ + new Message({ id: 'm1', accountId: 'a1', subject: 'Written' }), + new Message({ id: 'm2', accountId: 'a1', subject: 'Missing' }), + ]); + + expect(staged.length).toEqual(1); + expect(staged[0].message.id).toEqual('m1'); + expect(path.basename(staged[0].filePath)).toEqual('Written.eml'); + }); + + it('uses an explicit filename when one is given', async () => { + engineWrites(() => true); + const staged = await stageMessagesAsEml( + [new Message({ id: 'm1', accountId: 'a1', subject: 'Hello' })], + { filename: 'Forwarded Message.eml' } + ); + expect(path.basename(staged[0].filePath)).toEqual('Forwarded Message.eml'); + }); + + it('does nothing when given no messages', async () => { + expect(await stageMessagesAsEml([])).toEqual([]); + expect(Actions.queueTask).not.toHaveBeenCalled(); + }); + + it('removes the staging directory of a message whose file never arrived', async () => { + engineWrites(() => false); + await stageMessagesAsEml([new Message({ id: 'm1', accountId: 'a1', subject: 'Missing' })]); + expect(fs.existsSync(path.dirname(queued[0].filepath))).toBe(false); + }); + + it('leaves the staging directory of a written file in place for the caller', async () => { + engineWrites(() => true); + const staged = await stageMessagesAsEml([ + new Message({ id: 'm1', accountId: 'a1', subject: 'Written' }), + ]); + expect(fs.existsSync(staged[0].filePath)).toBe(true); + }); +}); + +describe('discardStagedEml', function () { + it('removes the file and the staging directory around it', () => { + const dir = path.join(os.tmpdir(), 'mailspring-eml-spec-discard'); + const filePath = path.join(dir, 'Hello.eml'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(filePath, 'raw'); + + discardStagedEml(filePath); + expect(fs.existsSync(dir)).toBe(false); + }); + + it('refuses to touch a directory it did not create', () => { + const dir = path.join(os.tmpdir(), 'mailspring-spec-not-staging'); + const filePath = path.join(dir, 'Hello.eml'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(filePath, 'raw'); + + discardStagedEml(filePath); + expect(fs.existsSync(filePath)).toBe(true); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('does not throw when the file is already gone', () => { + expect(() => + discardStagedEml(path.join(os.tmpdir(), 'mailspring-eml-spec-absent', 'Hello.eml')) + ).not.toThrow(); + }); +}); + +describe('stageMessageAsEml', function () { + beforeEach(() => { + spyOn(Actions, 'queueTask'); + }); + + it('unwraps the single staged result', async () => { + spyOn(TaskQueue, 'waitForPerformRemote').andCallFake((task: GetMessageRFC2822Task) => { + fs.writeFileSync(task.filepath, 'raw'); + return Promise.resolve(); + }); + const staged = await stageMessageAsEml( + new Message({ id: 'm1', accountId: 'a1', subject: 'Hello' }) + ); + expect(path.basename(staged.filePath)).toEqual('Hello.eml'); + discardStagedEml(staged.filePath); + }); + + it('returns null when the fetch produced no file', async () => { + spyOn(TaskQueue, 'waitForPerformRemote').andCallFake(() => Promise.resolve()); + const staged = await stageMessageAsEml( + new Message({ id: 'm1', accountId: 'a1', subject: 'Hello' }) + ); + expect(staged).toBe(null); + }); +}); + +describe('stageThreadAsEml', function () { + it('returns null when the thread has no exportable message', async () => { + spyOn(Actions, 'queueTask'); + spyOn(DatabaseStore, 'findAll').andCallFake(() => { + const query: any = { + order() { + return this; + }, + limit() { + return this; + }, + then(callback) { + return Promise.resolve([]).then(callback); + }, + }; + return query; + }); + + expect(await stageThreadAsEml('t1')).toBe(null); + expect(Actions.queueTask).not.toHaveBeenCalled(); + }); +}); + +describe('stageThreadsAsEml', function () { + beforeEach(() => { + spyOn(Actions, 'queueTask'); + spyOn(TaskQueue, 'waitForPerformRemote').andCallFake(() => Promise.resolve()); + }); + + it('reports threads with no exportable message separately from failed fetches', async () => { + const a = new Message({ id: 'a', threadId: 't1', accountId: 'a1', subject: 'Hello' }); + spyOn(DatabaseStore, 'findAll').andCallFake((klass, where) => { + const results = where.threadId === 't1' ? [a] : []; + const query: any = { + order() { + return this; + }, + limit() { + return this; + }, + then(callback) { + return Promise.resolve(results).then(callback); + }, + }; + return query; + }); + + // Nothing is written, so t1 counts as a failed fetch rather than an + // unavailable thread — the two must not be conflated. + const { staged, unavailableThreadIds } = await stageThreadsAsEml(['t1', 't2']); + expect(staged).toEqual([]); + expect(unavailableThreadIds).toEqual(['t2']); + }); +}); diff --git a/app/src/components/drop-zone.tsx b/app/src/components/drop-zone.tsx index cb419d6a68..92d7b93b2d 100644 --- a/app/src/components/drop-zone.tsx +++ b/app/src/components/drop-zone.tsx @@ -70,8 +70,20 @@ export class DropZone extends React.Component {
{ - if (event.target instanceof HTMLElement && event.target.closest('[data-slate-editor]')) + // Drags that start inside the Slate editor (moving an inline image, say) + // are Slate's to manage - it sets the drop effect and shows a caret, and + // preventing the default here would hide it. Anything we've said we'll + // accept still needs preventDefault, even over the editor: a + // contenteditable refuses drags it has no way to insert - like a dragged + // thread, which carries only our own dataTransfer types - so without it + // no drop event ever fires in the middle of the composer. + if ( + event.target instanceof HTMLElement && + event.target.closest('[data-slate-editor]') && + !this.props.shouldAcceptDrop(event) + ) { return; + } const allowed = event.dataTransfer.effectAllowed; if (allowed && allowed !== 'all' && allowed !== 'uninitialized') { // Only set dropEffect if it's a valid value (not 'all' or 'uninitialized') diff --git a/app/src/drag-drop-types.ts b/app/src/drag-drop-types.ts new file mode 100644 index 0000000000..89a8bb1981 --- /dev/null +++ b/app/src/drag-drop-types.ts @@ -0,0 +1,20 @@ +/** + * Custom dataTransfer types used for drag and drop within the app. + * + * A drop target can only read `dataTransfer.types` during dragenter/dragover — + * the payload itself isn't readable until the drop lands. That's why the + * account ids are encoded into a type name rather than the payload: a target + * that can only accept threads from certain accounts has to decide before it + * can call getData. + */ + +/** JSON `{ threadIds, accountIds }`, set when threads are dragged. */ +export const ThreadsDragType = 'mailspring-threads-data'; + +/** Prefix of a value-less type carrying the dragged threads' account ids. */ +export const AccountsDragTypePrefix = 'mailspring-accounts='; + +export function accountIdsForDragTypes(types: readonly string[]): string[] { + const accountsType = types.find((t) => t.startsWith(AccountsDragTypePrefix)); + return (accountsType || '').replace(AccountsDragTypePrefix, '').split(','); +} diff --git a/app/src/global/mailspring-exports.d.ts b/app/src/global/mailspring-exports.d.ts index 35c19467dd..7cac21fc0c 100644 --- a/app/src/global/mailspring-exports.d.ts +++ b/app/src/global/mailspring-exports.d.ts @@ -204,6 +204,8 @@ export type FsUtils = typeof import('../fs-utils'); export const FsUtils: FsUtils; export type CanvasUtils = typeof import('../canvas-utils'); export const CanvasUtils: CanvasUtils; +export type DragDropTypes = typeof import('../drag-drop-types'); +export const DragDropTypes: DragDropTypes; export type RegExpUtils = typeof import('../regexp-utils').default; export const RegExpUtils: RegExpUtils; export type MenuHelpers = typeof import('../menu-helpers'); diff --git a/app/src/global/mailspring-exports.js b/app/src/global/mailspring-exports.js index bcbdf36df7..10a532749b 100644 --- a/app/src/global/mailspring-exports.js +++ b/app/src/global/mailspring-exports.js @@ -187,6 +187,7 @@ lazyLoad(`CalendarUtils`, 'calendar-utils'); lazyLoad(`ICSEventHelpers`, 'ics-event-helpers'); lazyLoad(`FsUtils`, 'fs-utils'); lazyLoad(`CanvasUtils`, 'canvas-utils'); +lazyLoad(`DragDropTypes`, 'drag-drop-types'); lazyLoad(`RegExpUtils`, 'regexp-utils'); lazyLoad(`MenuHelpers`, 'menu-helpers'); lazyLoad(`VirtualDOMUtils`, 'virtual-dom-utils'); diff --git a/app/src/services/eml-utils.ts b/app/src/services/eml-utils.ts index a21b0cecda..4ca63cf7b9 100644 --- a/app/src/services/eml-utils.ts +++ b/app/src/services/eml-utils.ts @@ -1,3 +1,17 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import crypto from 'crypto'; +import _ from 'underscore'; + +import { + Actions, + DatabaseStore, + Message, + TaskQueue, + GetMessageRFC2822Task, +} from 'mailspring-exports'; + /** * Generate a safe default .eml filename from a message subject. * @@ -19,3 +33,163 @@ export function defaultEmlFilename(subject: string): string { name = name.replace(/[.\s]+$/, ''); return `${name}.eml`; } + +export interface StagedEml { + message: Message; + filePath: string; +} + +/** + * Resolve the message that represents each of the given threads for the + * purposes of .eml export. + * + * .eml holds a single RFC2822 message, so a thread has to be narrowed to one. + * Everywhere in the app we use the same convention: the most recent message in + * the thread. Drafts are skipped — they only exist locally, so the sync engine + * has no raw source to hand back for them. + * + * Threads with no exportable message are omitted, so the result may be shorter + * than `threadIds`. + */ +export async function newestExportableMessagesForThreadIds( + threadIds: string[] +): Promise { + if (!threadIds.length) { + return []; + } + const messages = await Promise.all( + threadIds.map(async (threadId) => { + const found = await DatabaseStore.findAll(Message, { threadId, draft: false }) + .order(Message.attributes.date.descending()) + .limit(1); + return found.length ? found[0] : null; + }) + ); + return messages.filter((m) => m !== null); +} + +/** + * Ask the sync engine for the raw RFC2822 source of each message and write it + * to a temporary .eml file on disk. + * + * Each message is staged into its own randomly named subdirectory so that + * concurrent stages of the same message can't overwrite one another, and so + * that the file basename can be a clean, human-readable name (it becomes the + * attachment's display name when the file is attached to a draft). By default + * the name comes from the subject; pass `filename` to override it. + * + * The fetch is remote, so it may fail — and a GetMessageRFC2822Task can reach + * `complete` without having written anything. Messages whose file never + * appeared are omitted from the result, so callers should compare the returned + * length against what they passed in to detect partial failures. + */ +export async function stageMessagesAsEml( + messages: Message[], + { filename }: { filename?: string } = {} +): Promise { + if (!messages.length) { + return []; + } + + const staged = messages.map((message) => { + const token = crypto.randomBytes(4).toString('hex'); + const dir = path.join(os.tmpdir(), `mailspring-eml-${message.id}-${token}`); + const basename = filename || defaultEmlFilename(message.subject); + return { message, dir, filePath: path.join(dir, basename) }; + }); + + // Queue every fetch before awaiting any of them so a multi-message stage + // isn't serialized on the sync engine's round trips. + const tasks = staged.map(({ message, dir, filePath }) => { + fs.mkdirSync(dir, { recursive: true }); + const task = new GetMessageRFC2822Task({ + messageId: message.id, + accountId: message.accountId, + filepath: filePath, + }); + Actions.queueTask(task); + return task; + }); + + await Promise.all(tasks.map((task) => TaskQueue.waitForPerformRemote(task))); + + // Directories whose file never arrived are dead weight — drop them now, and + // leave the rest to discardStagedEml once the caller is done with the file. + const [written, missing] = _.partition(staged, ({ filePath }) => fs.existsSync(filePath)); + missing.forEach(({ dir }) => removeStagingDirectory(dir)); + + return written.map(({ message, filePath }) => ({ message, filePath })); +} + +/** + * Stage a single message, returning null if its source couldn't be fetched. + */ +export async function stageMessageAsEml( + message: Message, + options: { filename?: string } = {} +): Promise { + const [staged] = await stageMessagesAsEml([message], options); + return staged || null; +} + +/** + * Stage the message that represents a single thread, returning null if the + * thread has nothing exportable in it or its source couldn't be fetched. + */ +export async function stageThreadAsEml( + threadId: string, + options: { filename?: string } = {} +): Promise { + const [message] = await newestExportableMessagesForThreadIds([threadId]); + return message ? stageMessageAsEml(message, options) : null; +} + +/** + * Delete a file produced by stageMessagesAsEml, along with the staging + * directory it lives in. + * + * Staged files are consumed by copying them somewhere permanent — attaching + * one to a draft copies it into the attachment store — so the temporary copy + * is garbage the moment the caller is finished with it. Callers should invoke + * this once that's true (for attachments, from `addAttachment`'s `onCreated`). + * Failures are ignored: leaving a file behind in the temp directory is not + * worth interrupting the user over. + */ +export function discardStagedEml(filePath: string) { + removeStagingDirectory(path.dirname(filePath)); +} + +const STAGING_DIR_PREFIX = 'mailspring-eml-'; + +function removeStagingDirectory(dir: string) { + // Guard against deleting anything we didn't create ourselves — callers hand + // us paths, and a wrong one shouldn't take a real directory with it. + if (!path.basename(dir).startsWith(STAGING_DIR_PREFIX)) { + return; + } + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch (err) { + // best effort + } +} + +export interface StagedThreads { + staged: StagedEml[]; + /** + * Threads that hold nothing exportable — a conversation containing only + * unsent drafts, say. Nothing was fetched for these, so they're a distinct + * outcome from a fetch that was attempted and failed. + */ + unavailableThreadIds: string[]; +} + +/** + * Convenience wrapper over the two steps above: turn a set of thread ids into + * staged .eml files ready to be attached to a draft. + */ +export async function stageThreadsAsEml(threadIds: string[]): Promise { + const messages = await newestExportableMessagesForThreadIds(threadIds); + const unavailableThreadIds = threadIds.filter((id) => !messages.some((m) => m.threadId === id)); + return { staged: await stageMessagesAsEml(messages), unavailableThreadIds }; +}