fix(mail-merge): Refactor mass sending procedure

Summary:
This diff introduces several updates to mail merge to improve the procedure for sending a list of drafts.
Specifically, sending mass email will now:

- Clear mail merge metadata on the drafts that will actually be sent
- Upload attached files only /once/, and reuse those files on the drafts that will actually be sent
- Minimize database writes for new drafts being created
- Will queue a SendManyDraftsTask that will subsequently queue the necessary SendDraftTasks and keep track of them, and notify of any failed tasks

TODO:
- Add state to MailMerge plugin for failed sends and ability to attempt to re send them

Test Plan: - TODO

Reviewers: evan, bengotow, jackie

Reviewed By: bengotow, jackie

Subscribers: jackie

Differential Revision: https://phab.nylas.com/D2973
This commit is contained in:
Juan Tejada 2016-05-18 16:19:42 -07:00
parent c9ea5b6483
commit a4ee61eadc
26 changed files with 397 additions and 266 deletions

View file

@ -2,7 +2,7 @@ import _ from 'underscore';
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import AccountContactField from './account-contact-field'; import AccountContactField from './account-contact-field';
import {Utils, Actions, AccountStore} from 'nylas-exports'; import {Utils, DraftHelpers, Actions, AccountStore} from 'nylas-exports';
import {InjectedComponent, KeyCommandsRegion, ParticipantsTextField, ListensToFluxStore} from 'nylas-component-kit'; import {InjectedComponent, KeyCommandsRegion, ParticipantsTextField, ListensToFluxStore} from 'nylas-component-kit';
import CollapsedParticipants from './collapsed-participants'; import CollapsedParticipants from './collapsed-participants';
@ -105,7 +105,7 @@ export default class ComposerHeader extends React.Component {
if (_.isEmpty(this.props.draft.subject)) { if (_.isEmpty(this.props.draft.subject)) {
return true; return true;
} }
if (Utils.isForwardedMessage(this.props.draft)) { if (DraftHelpers.isForwardedMessage(this.props.draft)) {
return true; return true;
} }
if (this.props.draft.replyToMessageId) { if (this.props.draft.replyToMessageId) {
@ -153,7 +153,7 @@ export default class ComposerHeader extends React.Component {
if (isDropping) { if (isDropping) {
this.setState({ this.setState({
participantsFocused: true, participantsFocused: true,
enabledFields: [...Fields.ParticipantFields, Fields.Subject], enabledFields: [...Fields.ParticipantFields, Fields.From, Fields.Subject],
}) })
} }
} }

View file

@ -7,10 +7,10 @@ import {
Utils, Utils,
Actions, Actions,
DraftStore, DraftStore,
ContactStore, UndoManager,
QuotedHTMLTransformer, DraftHelpers,
FileDownloadStore, FileDownloadStore,
ExtensionRegistry, QuotedHTMLTransformer,
} from 'nylas-exports'; } from 'nylas-exports';
import { import {
@ -55,7 +55,7 @@ export default class ComposerView extends React.Component {
constructor(props) { constructor(props) {
super(props) super(props)
this.state = { this.state = {
showQuotedText: Utils.isForwardedMessage(props.draft), showQuotedText: DraftHelpers.isForwardedMessage(props.draft),
} }
} }
@ -65,14 +65,14 @@ export default class ComposerView extends React.Component {
} }
} }
componentWillReceiveProps(newProps) { componentWillReceiveProps(nextProps) {
if (newProps.session !== this.props.session) { if (nextProps.session !== this.props.session) {
this._teardownForProps(); this._teardownForProps();
this._setupForProps(newProps); this._setupForProps(nextProps);
} }
if (Utils.isForwardedMessage(this.props.draft) !== Utils.isForwardedMessage(newProps.draft)) { if (DraftHelpers.isForwardedMessage(this.props.draft) !== DraftHelpers.isForwardedMessage(nextProps.draft)) {
this.setState({ this.setState({
showQuotedText: Utils.isForwardedMessage(newProps.draft), showQuotedText: DraftHelpers.isForwardedMessage(nextProps.draft),
}); });
} }
} }
@ -507,56 +507,19 @@ export default class ComposerView extends React.Component {
} }
const dialog = remote.dialog; const dialog = remote.dialog;
const {session} = this.props
const {errors, warnings} = session.validateDraftForSending()
const {to, cc, bcc, body, files, uploads} = this.props.draft; if (errors.length > 0) {
const allRecipients = [].concat(to, cc, bcc);
let dealbreaker = null;
for (const contact of allRecipients) {
if (!ContactStore.isValidContact(contact)) {
dealbreaker = `${contact.email} is not a valid email address - please remove or edit it before sending.`
}
}
if (allRecipients.length === 0) {
dealbreaker = 'You need to provide one or more recipients before sending the message.';
}
if (dealbreaker) {
dialog.showMessageBox(remote.getCurrentWindow(), { dialog.showMessageBox(remote.getCurrentWindow(), {
type: 'warning', type: 'warning',
buttons: ['Edit Message', 'Cancel'], buttons: ['Edit Message', 'Cancel'],
message: 'Cannot Send', message: 'Cannot Send',
detail: dealbreaker, detail: errors[0],
}); });
return false; return false;
} }
const bodyIsEmpty = body === this.props.session.draftPristineBody();
const forwarded = Utils.isForwardedMessage(this.props.draft);
const hasAttachment = (files || []).length > 0 || (uploads || []).length > 0;
let warnings = [];
if (this.props.draft.subject.length === 0) {
warnings.push('without a subject line');
}
if (this._mentionsAttachment(this.props.draft.body) && !hasAttachment) {
warnings.push('without an attachment');
}
if (bodyIsEmpty && !forwarded && !hasAttachment) {
warnings.push('without a body');
}
// Check third party warnings added via Composer extensions
for (const extension of ExtensionRegistry.Composer.extensions()) {
if (!extension.warningsForSending) {
continue;
}
warnings = warnings.concat(extension.warningsForSending({draft: this.props.draft}));
}
if ((warnings.length > 0) && (!options.force)) { if ((warnings.length > 0) && (!options.force)) {
const response = dialog.showMessageBox(remote.getCurrentWindow(), { const response = dialog.showMessageBox(remote.getCurrentWindow(), {
type: 'warning', type: 'warning',
@ -569,7 +532,6 @@ export default class ComposerView extends React.Component {
} }
return false; return false;
} }
return true; return true;
} }
@ -585,15 +547,62 @@ export default class ComposerView extends React.Component {
Actions.selectAttachment({messageClientId: this.props.draft.clientId}); Actions.selectAttachment({messageClientId: this.props.draft.clientId});
} }
_mentionsAttachment = (body) => { undo = (event) => {
let cleaned = QuotedHTMLTransformer.removeQuotedHTML(body.toLowerCase().trim()); event.preventDefault();
const signatureIndex = cleaned.indexOf('<signature>'); event.stopPropagation();
if (signatureIndex !== -1) {
cleaned = cleaned.substr(0, signatureIndex - 1); const historyItem = this.undoManager.undo() || {};
if (!historyItem.state) {
return;
} }
return (cleaned.indexOf("attach") >= 0);
this._recoveredSelection = historyItem.currentSelection;
this._applyChanges(historyItem.state, {fromUndoManager: true});
this._recoveredSelection = null;
} }
redo = (event) => {
event.preventDefault();
event.stopPropagation();
const historyItem = this.undoManager.redo() || {}
if (!historyItem.state) {
return;
}
this._recoveredSelection = historyItem.currentSelection;
this._applyChanges(historyItem.state, {fromUndoManager: true});
this._recoveredSelection = null;
}
_getSelections = () => {
const bodyComponent = this.refs[Fields.Body];
return {
currentSelection: bodyComponent.getCurrentSelection ? bodyComponent.getCurrentSelection() : null,
previousSelection: bodyComponent.getPreviousSelection ? bodyComponent.getPreviousSelection() : null,
}
}
_saveToHistory = (selections) => {
const {previousSelection, currentSelection} = selections || this._getSelections();
const historyItem = {
previousSelection,
currentSelection,
state: {
body: _.clone(this.props.draft.body),
subject: _.clone(this.props.draft.subject),
to: _.clone(this.props.draft.to),
cc: _.clone(this.props.draft.cc),
bcc: _.clone(this.props.draft.bcc),
},
}
const lastState = this.undoManager.current()
if (lastState) {
lastState.currentSelection = historyItem.previousSelection;
}
this.undoManager.saveToHistory(historyItem);
}
render() { render() {
const dropCoverDisplay = this.state.isDropping ? 'block' : 'none'; const dropCoverDisplay = this.state.isDropping ? 'block' : 'none';

View file

@ -77,8 +77,8 @@ describe "ComposerView", ->
@draft = draft @draft = draft
spyOn(ContactStore, "searchContacts").andCallFake (email) => spyOn(ContactStore, "searchContacts").andCallFake (email) =>
return _.filter(users, (u) u.email.toLowerCase() is email.toLowerCase()) return _.filter(users, (u) u.email.toLowerCase() is email.toLowerCase())
spyOn(ContactStore, "isValidContact").andCallFake (contact) => spyOn(Contact.prototype, "isValid").andCallFake (contact) ->
return contact.email.indexOf('@') > 0 return @email.indexOf('@') > 0
afterEach -> afterEach ->
ComposerEditor.containerRequired = undefined ComposerEditor.containerRequired = undefined

View file

@ -1,7 +1,7 @@
React = require 'react' React = require 'react'
_ = require 'underscore' _ = require 'underscore'
EmailFrame = require('./email-frame').default EmailFrame = require('./email-frame').default
{Utils, {DraftHelpers,
CanvasUtils, CanvasUtils,
NylasAPI, NylasAPI,
MessageUtils, MessageUtils,
@ -21,7 +21,7 @@ class MessageItemBody extends React.Component
constructor: (@props) -> constructor: (@props) ->
@_unmounted = false @_unmounted = false
@state = @state =
showQuotedText: Utils.isForwardedMessage(@props.message) showQuotedText: DraftHelpers.isForwardedMessage(@props.message)
processedBody: null processedBody: null
error: null error: null

View file

@ -1,7 +1,7 @@
_ = require 'underscore' _ = require 'underscore'
React = require 'react' React = require 'react'
{Actions, {DraftHelpers,
Utils, Actions,
Thread, Thread,
ChangeStarredTask, ChangeStarredTask,
AccountStore} = require 'nylas-exports' AccountStore} = require 'nylas-exports'
@ -25,7 +25,7 @@ class ThreadListIcon extends React.Component
last = msgs[msgs.length - 1] last = msgs[msgs.length - 1]
if msgs.length > 1 and last.from[0]?.isMe() if msgs.length > 1 and last.from[0]?.isMe()
if Utils.isForwardedMessage(last) if DraftHelpers.isForwardedMessage(last)
return 'thread-icon-forwarded thread-icon-star-on-hover' return 'thread-icon-forwarded thread-icon-star-on-hover'
else else
return 'thread-icon-replied thread-icon-star-on-hover' return 'thread-icon-replied thread-icon-star-on-hover'

View file

@ -154,3 +154,24 @@ describe "Contact", ->
spyOn(AccountStore, 'accountForEmail').andReturn(acct) spyOn(AccountStore, 'accountForEmail').andReturn(acct)
expect(c1.isMe()).toBe(true) expect(c1.isMe()).toBe(true)
expect(AccountStore.accountForEmail).toHaveBeenCalled() expect(AccountStore.accountForEmail).toHaveBeenCalled()
describe 'isValid', ->
it "should return true for a variety of valid contacts", ->
expect((new Contact(name: 'Ben', email: 'ben@nylas.com')).isValid()).toBe(true)
expect((new Contact(email: 'ben@nylas.com')).isValid()).toBe(true)
expect((new Contact(email: 'ben+123@nylas.com')).isValid()).toBe(true)
it "should return false if the contact has no email", ->
expect((new Contact(name: 'Ben')).isValid()).toBe(false)
it "should return false if the contact has an email that is not valid", ->
expect((new Contact(name: 'Ben', email:'Ben <ben@nylas.com>')).isValid()).toBe(false)
expect((new Contact(name: 'Ben', email:'<ben@nylas.com>')).isValid()).toBe(false)
expect((new Contact(name: 'Ben', email:'"ben@nylas.com"')).isValid()).toBe(false)
it "returns false if the email doesn't satisfy the regex", ->
expect((new Contact(name: "test", email: "foo")).isValid()).toBe false
it "returns false if the email doesn't match", ->
expect((new Contact(name: "test", email: "foo@")).isValid()).toBe false

View file

@ -117,34 +117,17 @@ describe "ContactStore", ->
expect(results.length).toBe 6 expect(results.length).toBe 6
describe 'isValidContact', -> describe 'isValidContact', ->
it "should return true for a variety of valid contacts", -> it "should call contact.isValid", ->
expect(ContactStore.isValidContact(new Contact(name: 'Ben', email: 'ben@nylas.com'))).toBe(true) contact = new Contact()
expect(ContactStore.isValidContact(new Contact(email: 'ben@nylas.com'))).toBe(true) spyOn(contact, 'isValid').andReturn(true)
expect(ContactStore.isValidContact(new Contact(email: 'ben+123@nylas.com'))).toBe(true) expect(ContactStore.isValidContact(contact)).toBe(true)
it "should return false for non-Contact objects", -> it "should return false for non-Contact objects", ->
expect(ContactStore.isValidContact({name: 'Ben', email: 'ben@nylas.com'})).toBe(false) expect(ContactStore.isValidContact({name: 'Ben', email: 'ben@nylas.com'})).toBe(false)
it "should return false if the contact has no email", ->
expect(ContactStore.isValidContact(new Contact(name: 'Ben'))).toBe(false)
it "should return false if the contact has an email that is not valid", ->
expect(ContactStore.isValidContact(new Contact(name: 'Ben', email:'Ben <ben@nylas.com>'))).toBe(false)
expect(ContactStore.isValidContact(new Contact(name: 'Ben', email:'<ben@nylas.com>'))).toBe(false)
expect(ContactStore.isValidContact(new Contact(name: 'Ben', email:'"ben@nylas.com"'))).toBe(false)
it "returns false if we're not passed a contact", -> it "returns false if we're not passed a contact", ->
expect(ContactStore.isValidContact()).toBe false expect(ContactStore.isValidContact()).toBe false
it "returns false if the contact doesn't have an email", ->
expect(ContactStore.isValidContact(new Contact(name: "test"))).toBe false
it "returns false if the email doesn't satisfy the regex", ->
expect(ContactStore.isValidContact(new Contact(name: "test", email: "foo"))).toBe false
it "returns false if the email doesn't match", ->
expect(ContactStore.isValidContact(new Contact(name: "test", email: "foo@"))).toBe false
describe 'parseContactsInString', -> describe 'parseContactsInString', ->
testCases = testCases =
# Single contact test cases # Single contact test cases

View file

@ -0,0 +1,37 @@
import {
Actions,
Message,
DraftHelpers,
SyncbackDraftFilesTask,
} from 'nylas-exports';
describe('DraftHelpers', function describeBlock() {
describe('prepareForSyncback', () => {
beforeEach(() => {
spyOn(DraftHelpers, 'applyExtensionTransformsToDraft').andCallFake((draft) => Promise.resolve(draft))
spyOn(Actions, 'queueTask')
});
it('queues tasks to upload files and send the draft', () => {
const draft = new Message({
clientId: "local-123",
threadId: "thread-123",
replyToMessageId: "message-123",
uploads: ['stub'],
});
const session = {
ensureCorrectAccount() { return Promise.resolve() },
draft() { return draft },
}
runs(() => {
DraftHelpers.prepareDraftForSyncback(session);
});
waitsFor(() => Actions.queueTask.calls.length > 0);
runs(() => {
const saveAttachments = Actions.queueTask.calls[0].args[0];
expect(saveAttachments instanceof SyncbackDraftFilesTask).toBe(true);
expect(saveAttachments.draftClientId).toBe(draft.clientId);
});
});
});
});

View file

@ -5,15 +5,14 @@ import {
Message, Message,
Account, Account,
DraftStore, DraftStore,
DraftHelpers,
DatabaseStore, DatabaseStore,
SoundRegistry, SoundRegistry,
SendDraftTask,
DestroyDraftTask, DestroyDraftTask,
ComposerExtension, ComposerExtension,
ExtensionRegistry, ExtensionRegistry,
FocusedContentStore, FocusedContentStore,
DatabaseTransaction, DatabaseTransaction,
SyncbackDraftFilesTask,
} from 'nylas-exports'; } from 'nylas-exports';
import {remote} from 'electron'; import {remote} from 'electron';
@ -323,7 +322,7 @@ describe('DraftStore', function draftStore() {
DraftStore._draftSessions[this.draft.clientId] = session; DraftStore._draftSessions[this.draft.clientId] = session;
spyOn(DraftStore, "_doneWithSession").andCallThrough(); spyOn(DraftStore, "_doneWithSession").andCallThrough();
spyOn(DraftStore, "_prepareForSyncback").andReturn(Promise.resolve()); spyOn(DraftHelpers, "prepareDraftForSyncback").andReturn(Promise.resolve());
spyOn(DraftStore, "trigger"); spyOn(DraftStore, "trigger");
spyOn(SoundRegistry, "playSound"); spyOn(SoundRegistry, "playSound");
spyOn(Actions, "queueTask"); spyOn(Actions, "queueTask");
@ -408,25 +407,10 @@ describe('DraftStore', function draftStore() {
}); });
}); });
it("queues tasks to upload files and send the draft", () => {
runs(() => {
DraftStore._onSendDraft(this.draft.clientId);
});
waitsFor(() => Actions.queueTask.calls.length > 0);
runs(() => {
const saveAttachments = Actions.queueTask.calls[0].args[0];
expect(saveAttachments instanceof SyncbackDraftFilesTask).toBe(true);
expect(saveAttachments.draftClientId).toBe(this.draft.clientId);
const sendDraft = Actions.queueTask.calls[1].args[0];
expect(sendDraft instanceof SendDraftTask).toBe(true);
expect(sendDraft.draftClientId).toBe(this.draft.clientId);
});
});
it("resets the sending state if there's an error", () => { it("resets the sending state if there's an error", () => {
spyOn(NylasEnv, "isMainWindow").andReturn(false); spyOn(NylasEnv, "isMainWindow").andReturn(false);
DraftStore._draftsSending[this.draft.clientId] = true; DraftStore._draftsSending[this.draft.clientId] = true;
Actions.draftSendingFailed({errorMessage: "boohoo", draftClientId: this.draft.clientId}); Actions.sendDraftFailed({errorMessage: "boohoo", draftClientId: this.draft.clientId});
expect(DraftStore.isSendingDraft(this.draft.clientId)).toBe(false); expect(DraftStore.isSendingDraft(this.draft.clientId)).toBe(false);
expect(DraftStore.trigger).toHaveBeenCalledWith(this.draft.clientId); expect(DraftStore.trigger).toHaveBeenCalledWith(this.draft.clientId);
}); });
@ -437,7 +421,7 @@ describe('DraftStore', function draftStore() {
spyOn(remote.dialog, "showMessageBox"); spyOn(remote.dialog, "showMessageBox");
spyOn(Actions, "composePopoutDraft"); spyOn(Actions, "composePopoutDraft");
DraftStore._draftsSending[this.draft.clientId] = true; DraftStore._draftsSending[this.draft.clientId] = true;
Actions.draftSendingFailed({threadId: 't1', errorMessage: "boohoo", draftClientId: this.draft.clientId}); Actions.sendDraftFailed({threadId: 't1', errorMessage: "boohoo", draftClientId: this.draft.clientId});
advanceClock(200); advanceClock(200);
expect(DraftStore.isSendingDraft(this.draft.clientId)).toBe(false); expect(DraftStore.isSendingDraft(this.draft.clientId)).toBe(false);
expect(DraftStore.trigger).toHaveBeenCalledWith(this.draft.clientId); expect(DraftStore.trigger).toHaveBeenCalledWith(this.draft.clientId);
@ -452,7 +436,7 @@ describe('DraftStore', function draftStore() {
spyOn(FocusedContentStore, "focused").andReturn({id: "t1"}); spyOn(FocusedContentStore, "focused").andReturn({id: "t1"});
spyOn(Actions, "composePopoutDraft"); spyOn(Actions, "composePopoutDraft");
DraftStore._draftsSending[this.draft.clientId] = true; DraftStore._draftsSending[this.draft.clientId] = true;
Actions.draftSendingFailed({threadId: 't2', errorMessage: "boohoo", draftClientId: this.draft.clientId}); Actions.sendDraftFailed({threadId: 't2', errorMessage: "boohoo", draftClientId: this.draft.clientId});
advanceClock(200); advanceClock(200);
expect(Actions.composePopoutDraft).toHaveBeenCalled(); expect(Actions.composePopoutDraft).toHaveBeenCalled();
const call = Actions.composePopoutDraft.calls[0]; const call = Actions.composePopoutDraft.calls[0];
@ -465,7 +449,7 @@ describe('DraftStore', function draftStore() {
spyOn(Actions, "composePopoutDraft"); spyOn(Actions, "composePopoutDraft");
DraftStore._draftsSending[this.draft.clientId] = true; DraftStore._draftsSending[this.draft.clientId] = true;
spyOn(FocusedContentStore, "focused").andReturn(null); spyOn(FocusedContentStore, "focused").andReturn(null);
Actions.draftSendingFailed({errorMessage: "boohoo", draftClientId: this.draft.clientId}); Actions.sendDraftFailed({errorMessage: "boohoo", draftClientId: this.draft.clientId});
advanceClock(200); advanceClock(200);
expect(Actions.composePopoutDraft).toHaveBeenCalled(); expect(Actions.composePopoutDraft).toHaveBeenCalled();
const call = Actions.composePopoutDraft.calls[0]; const call = Actions.composePopoutDraft.calls[0];

View file

@ -222,7 +222,7 @@ describe('SendDraftTask', function sendDraftTask() {
describe("when there are errors", () => { describe("when there are errors", () => {
beforeEach(() => { beforeEach(() => {
spyOn(Actions, 'draftSendingFailed'); spyOn(Actions, 'sendDraftFailed');
jasmine.unspy(NylasAPI, "makeRequest"); jasmine.unspy(NylasAPI, "makeRequest");
}); });
@ -238,7 +238,7 @@ describe('SendDraftTask', function sendDraftTask() {
waitsForPromise(() => this.task.performRemote().then((status) => { waitsForPromise(() => this.task.performRemote().then((status) => {
expect(status[0]).toBe(Task.Status.Failed); expect(status[0]).toBe(Task.Status.Failed);
expect(status[1]).toBe(thrownError); expect(status[1]).toBe(thrownError);
expect(Actions.draftSendingFailed).toHaveBeenCalled(); expect(Actions.sendDraftFailed).toHaveBeenCalled();
expect(NylasEnv.reportError).toHaveBeenCalled(); expect(NylasEnv.reportError).toHaveBeenCalled();
})); }));
}); });
@ -300,7 +300,7 @@ describe('SendDraftTask', function sendDraftTask() {
waitsForPromise(() => this.task.performRemote().then((status) => { waitsForPromise(() => this.task.performRemote().then((status) => {
expect(status[0]).toBe(Task.Status.Failed); expect(status[0]).toBe(Task.Status.Failed);
expect(status[1]).toBe(thrownError); expect(status[1]).toBe(thrownError);
expect(Actions.draftSendingFailed).toHaveBeenCalled(); expect(Actions.sendDraftFailed).toHaveBeenCalled();
})); }));
}); });
@ -312,7 +312,7 @@ describe('SendDraftTask', function sendDraftTask() {
waitsForPromise(() => this.task.performRemote().then((status) => { waitsForPromise(() => this.task.performRemote().then((status) => {
expect(status[0]).toBe(Task.Status.Failed); expect(status[0]).toBe(Task.Status.Failed);
expect(status[1]).toBe(thrownError); expect(status[1]).toBe(thrownError);
expect(Actions.draftSendingFailed).toHaveBeenCalled(); expect(Actions.sendDraftFailed).toHaveBeenCalled();
})); }));
}); });
@ -341,9 +341,9 @@ describe('SendDraftTask', function sendDraftTask() {
waitsForPromise(() => this.task.performRemote().then((status) => { waitsForPromise(() => this.task.performRemote().then((status) => {
expect(status[0]).toBe(Task.Status.Failed); expect(status[0]).toBe(Task.Status.Failed);
expect(status[1]).toBe(thrownError); expect(status[1]).toBe(thrownError);
expect(Actions.draftSendingFailed).toHaveBeenCalled(); expect(Actions.sendDraftFailed).toHaveBeenCalled();
const msg = Actions.draftSendingFailed.calls[0].args[0].errorMessage; const msg = Actions.sendDraftFailed.calls[0].args[0].errorMessage;
expect(withoutWhitespace(msg)).toEqual(withoutWhitespace(expectedMessage)); expect(withoutWhitespace(msg)).toEqual(withoutWhitespace(expectedMessage));
})); }));
}); });
@ -365,9 +365,9 @@ describe('SendDraftTask', function sendDraftTask() {
waitsForPromise(() => this.task.performRemote().then((status) => { waitsForPromise(() => this.task.performRemote().then((status) => {
expect(status[0]).toBe(Task.Status.Failed); expect(status[0]).toBe(Task.Status.Failed);
expect(status[1]).toBe(thrownError); expect(status[1]).toBe(thrownError);
expect(Actions.draftSendingFailed).toHaveBeenCalled(); expect(Actions.sendDraftFailed).toHaveBeenCalled();
const msg = Actions.draftSendingFailed.calls[0].args[0].errorMessage; const msg = Actions.sendDraftFailed.calls[0].args[0].errorMessage;
expect(withoutWhitespace(msg)).toEqual(withoutWhitespace(expectedMessage)); expect(withoutWhitespace(msg)).toEqual(withoutWhitespace(expectedMessage));
})); }));
}); });

View file

@ -4,7 +4,7 @@ import {findDOMNode} from 'react-dom'
const MIN_RANGE_SIZE = 2 const MIN_RANGE_SIZE = 2
function getRange({total, itemHeight, containerHeight, scrollTop}) { function getRange({total, itemHeight, containerHeight, scrollTop = 0} = {}) {
const itemsPerBody = Math.floor((containerHeight) / itemHeight); const itemsPerBody = Math.floor((containerHeight) / itemHeight);
const start = Math.max(0, Math.floor(scrollTop / itemHeight) - (itemsPerBody * 2)); const start = Math.max(0, Math.floor(scrollTop / itemHeight) - (itemsPerBody * 2));
const end = Math.max(MIN_RANGE_SIZE, Math.min(start + (4 * itemsPerBody), total)); const end = Math.max(MIN_RANGE_SIZE, Math.min(start + (4 * itemsPerBody), total));
@ -22,6 +22,7 @@ class LazyRenderedList extends Component {
} }
static defaultProps = { static defaultProps = {
items: [],
itemHeight: 30, itemHeight: 30,
containerHeight: 150, containerHeight: 150,
BufferTag: 'div', BufferTag: 'div',
@ -29,7 +30,7 @@ class LazyRenderedList extends Component {
constructor(props) { constructor(props) {
super(props) super(props)
this.state = {start: 0, end: MIN_RANGE_SIZE} this.state = this.getRangeState(props)
} }
componentWillReceiveProps(nextProps) { componentWillReceiveProps(nextProps) {
@ -40,9 +41,13 @@ class LazyRenderedList extends Component {
this.updateRangeState(this.props) this.updateRangeState(this.props)
} }
updateRangeState({itemHeight, items, containerHeight}) { getRangeState({items, itemHeight, containerHeight, scrollTop}) {
return getRange({total: items.length, itemHeight, containerHeight, scrollTop})
}
updateRangeState(props) {
const {scrollTop} = findDOMNode(this) const {scrollTop} = findDOMNode(this)
this.setState(getRange({total: items.length, itemHeight, containerHeight, scrollTop})) this.setState(this.getRangeState({...props, scrollTop}))
} }
renderItems() { renderItems() {

View file

@ -121,6 +121,15 @@ export default class TableDataSource {
return {...this._tableData} return {...this._tableData}
} }
filterRows(filterFn) {
const rows = this.rows()
const nextRows = rows.filter(filterFn)
return new TableDataSource({
...this._tableData,
rows: nextRows,
})
}
/** /**
* Adds column * Adds column
* *

View file

@ -83,8 +83,8 @@ class Actions
Recieves the clientId of the message that was sent Recieves the clientId of the message that was sent
### ###
@sendDraftSuccess: ActionScopeGlobal @sendDraftSuccess: ActionScopeGlobal
@sendDraftFailed: ActionScopeGlobal
@sendToAllWindows: ActionScopeGlobal @sendToAllWindows: ActionScopeGlobal
@draftSendingFailed: ActionScopeGlobal
### ###
Public: Queue a {Task} object to the {TaskQueue}. Public: Queue a {Task} object to the {TaskQueue}.
@ -367,7 +367,7 @@ class Actions
``` ```
### ###
@sendDraft: ActionScopeWindow @sendDraft: ActionScopeWindow
@sendDrafts: ActionScopeWindow @sendManyDrafts: ActionScopeWindow
@ensureDraftSynced: ActionScopeWindow @ensureDraftSynced: ActionScopeWindow
### ###

View file

@ -91,7 +91,17 @@ class Contact extends Model
json['name'] ||= json['email'] json['name'] ||= json['email']
json json
# Public: Returns true if the contact provided is a {Contact} instance and
# contains a properly formatted email address.
#
isValid: -> isValid: ->
return false unless @email
# The email regexp must match the /entire/ email address
result = RegExpUtils.emailRegex().exec(@email)
if result and result instanceof Array
return result[0] is @email
else return false
@email.match(RegExpUtils.emailRegex()) != null @email.match(RegExpUtils.emailRegex()) != null
# Public: Returns true if the contact is the current user, false otherwise. # Public: Returns true if the contact is the current user, false otherwise.

View file

@ -4,11 +4,12 @@ import moment from 'moment'
import File from './file' import File from './file'
import Utils from './utils' import Utils from './utils'
import Event from './event' import Event from './event'
import Category from './category'
import Contact from './contact' import Contact from './contact'
import Category from './category'
import Attributes from '../attributes' import Attributes from '../attributes'
import ModelWithMetadata from './model-with-metadata' import ModelWithMetadata from './model-with-metadata'
/** /**
Public: The Message model represents a Message object served by the Nylas Platform API. Public: The Message model represents a Message object served by the Nylas Platform API.
For more information about Messages on the Nylas Platform, read the For more information about Messages on the Nylas Platform, read the

View file

@ -223,25 +223,6 @@ Utils =
else else
return "#{prefix} #{subject}" return "#{prefix} #{subject}"
# Returns true if the message contains "Forwarded" or "Fwd" in the first
# 250 characters. A strong indicator that the quoted text should be
# shown. Needs to be limited to first 250 to prevent replies to
# forwarded messages from also being expanded.
isForwardedMessage: ({body, subject} = {}) ->
bodyForwarded = false
bodyFwd = false
subjectFwd = false
if body
indexForwarded = body.search(/forwarded/i)
bodyForwarded = indexForwarded >= 0 and indexForwarded < 250
indexFwd = body.search(/fwd/i)
bodyFwd = indexFwd >= 0 and indexFwd < 250
if subject
subjectFwd = subject[0...3].toLowerCase() is "fwd"
return bodyForwarded or bodyFwd or subjectFwd
# True of all arguments have the same domains # True of all arguments have the same domains
emailsHaveSameDomain: (args...) -> emailsHaveSameDomain: (args...) ->
return false if args.length < 2 return false if args.length < 2

View file

@ -80,18 +80,9 @@ class ContactStore extends NylasStore
return Promise.resolve(results) return Promise.resolve(results)
# Public: Returns true if the contact provided is a {Contact} instance and
# contains a properly formatted email address.
#
isValidContact: (contact) => isValidContact: (contact) =>
return false unless contact instanceof Contact return false unless contact instanceof Contact
return false unless contact.email return contact.isValid()
# The email regexp must match the /entire/ email address
result = RegExpUtils.emailRegex().exec(contact.email)
if result and result instanceof Array
return result[0] is contact.email
else return false
parseContactsInString: (contactString, options={}) => parseContactsInString: (contactString, options={}) =>
{skipNameLookup} = options {skipNameLookup} = options

View file

@ -1,13 +1,16 @@
Message = require('../models/message').default Message = require('../models/message').default
Actions = require '../actions' Actions = require '../actions'
NylasAPI = require '../nylas-api'
AccountStore = require './account-store'
ContactStore = require './contact-store'
DatabaseStore = require './database-store' DatabaseStore = require './database-store'
UndoStack = require '../../undo-stack' UndoStack = require '../../undo-stack'
ExtensionRegistry = require('../../extension-registry') DraftHelpers = require '../stores/draft-helpers'
ExtensionRegistry = require '../../extension-registry'
{Listener, Publisher} = require '../modules/reflux-coffee' {Listener, Publisher} = require '../modules/reflux-coffee'
SyncbackDraftTask = require('../tasks/syncback-draft-task').default SyncbackDraftTask = require('../tasks/syncback-draft-task').default
CoffeeHelpers = require '../coffee-helpers' CoffeeHelpers = require '../coffee-helpers'
DraftStore = null DraftStore = null
_ = require 'underscore' _ = require 'underscore'
MetadataChangePrefix = 'metadata.' MetadataChangePrefix = 'metadata.'
@ -143,6 +146,64 @@ class DraftEditingSession
@changes.teardown() @changes.teardown()
@_destroyed = true @_destroyed = true
validateDraftForSending: =>
warnings = []
errors = []
allRecipients = [].concat(@_draft.to, @_draft.cc, @_draft.bcc)
bodyIsEmpty = @_draft.body is @draftPristineBody()
forwarded = DraftHelpers.isForwardedMessage(@_draft)
hasAttachment = @_draft.files?.length > 0 or @_draft.uploads?.length > 0
for contact in allRecipients
if not ContactStore.isValidContact(contact)
errors.push("#{contact.email} is not a valid email address - please remove or edit it before sending.")
if allRecipients.length is 0
errors.push('You need to provide one or more recipients before sending the message.')
if errors.length > 0
return {errors, warnings}
if @_draft.subject.length is 0
warnings.push('without a subject line')
if DraftHelpers.messageMentionsAttachment(@_draft) and not hasAttachment
warnings.push('without an attachment')
if bodyIsEmpty and not forwarded and not hasAttachment
warnings.push('without a body')
## Check third party warnings added via Composer extensions
for extension in ExtensionRegistry.Composer.extensions()
continue if not extension.warningsForSending
warnings = warnings.concat(extension.warningsForSending({draft: @_draft}))
return {errors, warnings}
# This function makes sure the draft is attached to a valid account, and changes
# it's accountId if the from address does not match the account for the from
# address
#
# If the account is updated it makes a request to delete the draft with the
# old accountId
ensureCorrectAccount: ({noSyncback} = {}) =>
account = AccountStore.accountForEmail(@_draft.from[0].email)
if !account
return Promise.reject(new Error("DraftEditingSession::ensureCorrectAccount - you can only send drafts from a configured account."))
if account.id isnt @_draft.accountId
NylasAPI.makeDraftDeletionRequest(@_draft)
@changes.add({
accountId: account.id,
version: null,
serverId: null,
threadId: null,
replyToMessageId: null,
})
return @changes.commit({noSyncback})
.thenReturn(@)
return Promise.resolve(@)
_setDraft: (draft) -> _setDraft: (draft) ->
if !draft.body? if !draft.body?
throw new Error("DraftEditingSession._setDraft - new draft has no body!") throw new Error("DraftEditingSession._setDraft - new draft has no body!")

View file

@ -0,0 +1,96 @@
import _ from 'underscore'
import Actions from '../actions'
import DatabaseStore from './database-store'
import * as ExtensionRegistry from '../../extension-registry'
import SyncbackDraftFilesTask from '../tasks/syncback-draft-files-task'
import QuotedHTMLTransformer from '../../services/quoted-html-transformer'
export const AllowedTransformFields = ['to', 'from', 'cc', 'bcc', 'subject', 'body']
/**
* Returns true if the message contains "Forwarded" or "Fwd" in the first
* 250 characters. A strong indicator that the quoted text should be
* shown. Needs to be limited to first 250 to prevent replies to
* forwarded messages from also being expanded.
*/
export function isForwardedMessage({body, subject} = {}) {
let bodyFwd = false
let bodyForwarded = false
let subjectFwd = false
if (body) {
const indexFwd = body.search(/fwd/i)
const indexForwarded = body.search(/forwarded/i)
bodyForwarded = indexForwarded >= 0 && indexForwarded < 250
bodyFwd = indexFwd >= 0 && indexFwd < 250
}
if (subject) {
subjectFwd = subject.slice(0, 3).toLowerCase() === "fwd"
}
return bodyForwarded || bodyFwd || subjectFwd
}
export function messageMentionsAttachment({body} = {}) {
if (body == null) { throw new Error('DraftHelpers::messageMentionsAttachment - Message has no body loaded') }
let cleaned = QuotedHTMLTransformer.removeQuotedHTML(body.toLowerCase().trim());
const signatureIndex = cleaned.indexOf('<signature>');
if (signatureIndex !== -1) {
cleaned = cleaned.substr(0, signatureIndex - 1);
}
return (cleaned.indexOf("attach") >= 0);
}
export function queueDraftFileUploads(draft) {
if (draft.files.length > 0 || draft.uploads.length > 0) {
Actions.queueTask(new SyncbackDraftFilesTask(draft.clientId))
}
}
export function applyExtensionTransformsToDraft(draft) {
let latestTransformed = draft
const extensions = ExtensionRegistry.Composer.extensions()
const transformPromise = (
Promise.each(extensions, (ext) => {
const extApply = ext.applyTransformsToDraft
const extUnapply = ext.unapplyTransformsToDraft
if (!extApply || !extUnapply) {
return Promise.resolve()
}
return Promise.resolve(extUnapply({draft: latestTransformed})).then((cleaned) => {
const base = cleaned === 'unnecessary' ? latestTransformed : cleaned;
return Promise.resolve(extApply({draft: base})).then((transformed) => (
Promise.resolve(extUnapply({draft: transformed.clone()})).then((reverted) => {
const untransformed = reverted === 'unnecessary' ? base : reverted;
if (!_.isEqual(_.pick(untransformed, AllowedTransformFields), _.pick(base, AllowedTransformFields))) {
console.log("-- BEFORE --")
console.log(base.body)
console.log("-- TRANSFORMED --")
console.log(transformed.body)
console.log("-- UNTRANSFORMED (should match BEFORE) --")
console.log(untransformed.body)
NylasEnv.reportError(new Error(`Extension ${ext.name} applied a transform to the draft that it could not reverse.`))
}
latestTransformed = transformed
return Promise.resolve()
})
))
})
})
)
return transformPromise
.thenReturn(latestTransformed)
}
export function prepareDraftForSyncback(session) {
return session.ensureCorrectAccount({noSyncback: true})
.then(() => applyExtensionTransformsToDraft(session.draft()))
.then((transformed) => (
DatabaseStore.inTransaction((t) => t.persistModel(transformed))
.then(() => Promise.resolve(queueDraftFileUploads(transformed)))
.thenReturn(transformed)
))
}

View file

@ -4,6 +4,7 @@ _ = require 'underscore'
NylasAPI = require '../nylas-api' NylasAPI = require '../nylas-api'
DraftEditingSession = require './draft-editing-session' DraftEditingSession = require './draft-editing-session'
DraftHelpers = require './draft-helpers'
DraftFactory = require './draft-factory' DraftFactory = require './draft-factory'
DatabaseStore = require './database-store' DatabaseStore = require './database-store'
AccountStore = require './account-store' AccountStore = require './account-store'
@ -17,7 +18,6 @@ SyncbackDraftTask = require('../tasks/syncback-draft-task').default
DestroyDraftTask = require('../tasks/destroy-draft-task').default DestroyDraftTask = require('../tasks/destroy-draft-task').default
Thread = require('../models/thread').default Thread = require('../models/thread').default
Contact = require '../models/contact'
Message = require('../models/message').default Message = require('../models/message').default
Actions = require '../actions' Actions = require '../actions'
@ -55,7 +55,7 @@ class DraftStore
@listenTo Actions.sendDraftSuccess, => @trigger() @listenTo Actions.sendDraftSuccess, => @trigger()
@listenTo Actions.composePopoutDraft, @_onPopoutDraftClientId @listenTo Actions.composePopoutDraft, @_onPopoutDraftClientId
@listenTo Actions.composeNewBlankDraft, @_onPopoutBlankDraft @listenTo Actions.composeNewBlankDraft, @_onPopoutBlankDraft
@listenTo Actions.draftSendingFailed, @_onDraftSendingFailed @listenTo Actions.sendDraftFailed, @_onSendDraftFailed
@listenTo Actions.sendQuickReply, @_onSendQuickReply @listenTo Actions.sendQuickReply, @_onSendQuickReply
if NylasEnv.isMainWindow() if NylasEnv.isMainWindow()
@ -66,7 +66,6 @@ class DraftStore
# window. # window.
@listenTo Actions.ensureDraftSynced, @_onEnsureDraftSynced @listenTo Actions.ensureDraftSynced, @_onEnsureDraftSynced
@listenTo Actions.sendDraft, @_onSendDraft @listenTo Actions.sendDraft, @_onSendDraft
@listenTo Actions.sendDrafts, @_onSendDrafts
@listenTo Actions.destroyDraft, @_onDestroyDraft @listenTo Actions.destroyDraft, @_onDestroyDraft
@listenTo Actions.removeFile, @_onRemoveFile @listenTo Actions.removeFile, @_onRemoveFile
@ -335,92 +334,21 @@ class DraftStore
_onEnsureDraftSynced: (draftClientId) => _onEnsureDraftSynced: (draftClientId) =>
@sessionForClientId(draftClientId).then (session) => @sessionForClientId(draftClientId).then (session) =>
@_prepareForSyncback(session).then => DraftHelpers.prepareDraftForSyncback(session)
@_queueDraftAssetTasks(session.draft()) .then (preparedDraft) =>
Actions.queueTask(new SyncbackDraftTask(draftClientId)) Actions.queueTask(new SyncbackDraftTask(draftClientId))
_onSendDraft: (draftClientId) => _onSendDraft: (draftClientId) =>
@_sendDraft(draftClientId)
.then =>
if @_isPopout()
NylasEnv.close()
_onSendDrafts: (draftClientIds) =>
Promise.each(draftClientIds, (draftClientId) =>
@_sendDraft(draftClientId)
).then =>
if @_isPopout()
NylasEnv.close()
_sendDraft: (draftClientId) =>
@_draftsSending[draftClientId] = true @_draftsSending[draftClientId] = true
@sessionForClientId(draftClientId).then (session) => @sessionForClientId(draftClientId).then (session) =>
@_prepareForSyncback(session).then => DraftHelpers.prepareDraftForSyncback(session)
if NylasEnv.config.get("core.sending.sounds") .then (preparedDraft) =>
SoundRegistry.playSound('hit-send')
@_queueDraftAssetTasks(session.draft())
Actions.queueTask(new SendDraftTask(draftClientId)) Actions.queueTask(new SendDraftTask(draftClientId))
@_doneWithSession(session) @_doneWithSession(session)
Promise.resolve() if NylasEnv.config.get("core.sending.sounds")
SoundRegistry.playSound('hit-send')
_queueDraftAssetTasks: (draft) => if @_isPopout()
if draft.files.length > 0 or draft.uploads.length > 0 NylasEnv.close()
Actions.queueTask(new SyncbackDraftFilesTask(draft.clientId))
_isPopout: ->
NylasEnv.getWindowType() is "composer"
_prepareForSyncback: (session) =>
draft = session.draft()
# Make sure the draft is attached to a valid account, and change it's
# accountId if the from address does not match the current account.
account = AccountStore.accountForEmail(draft.from[0].email)
unless account
return Promise.reject(new Error("DraftStore::_prepareForSyncback - you can only send drafts from a configured account."))
if account.id isnt draft.accountId
NylasAPI.makeDraftDeletionRequest(draft)
session.changes.add({
accountId: account.id
version: null
serverId: null
threadId: null
replyToMessageId: null
})
# Run draft transformations registered by third-party plugins
allowedFields = ['to', 'from', 'cc', 'bcc', 'subject', 'body']
session.changes.commit(noSyncback: true).then =>
draft = session.draft().clone()
Promise.each @extensions(), (ext) ->
extApply = ext.applyTransformsToDraft
extUnapply = ext.unapplyTransformsToDraft
unless extApply and extUnapply
return Promise.resolve()
Promise.resolve(extUnapply({draft})).then (cleaned) =>
cleaned = draft if cleaned is 'unnecessary'
Promise.resolve(extApply({draft: cleaned})).then (transformed) =>
Promise.resolve(extUnapply({draft: transformed.clone()})).then (untransformed) =>
untransformed = cleaned if untransformed is 'unnecessary'
if not _.isEqual(_.pick(untransformed, allowedFields), _.pick(cleaned, allowedFields))
console.log("-- BEFORE --")
console.log(draft.body)
console.log("-- TRANSFORMED --")
console.log(transformed.body)
console.log("-- UNTRANSFORMED (should match BEFORE) --")
console.log(untransformed.body)
NylasEnv.reportError(new Error("Extension #{ext.name} applied a tranform to the draft that it could not reverse."))
draft = transformed
.then =>
DatabaseStore.inTransaction (t) =>
t.persistModel(draft)
__testExtensionTransforms: -> __testExtensionTransforms: ->
clientId = NylasEnv.getWindowProps().draftClientId clientId = NylasEnv.getWindowProps().draftClientId
@ -436,7 +364,7 @@ class DraftStore
session.changes.add({files}) session.changes.add({files})
session.changes.commit() session.changes.commit()
_onDraftSendingFailed: ({draftClientId, threadId, errorMessage}) -> _onSendDraftFailed: ({draftClientId, threadId, errorMessage}) ->
@_draftsSending[draftClientId] = false @_draftsSending[draftClientId] = false
@trigger(draftClientId) @trigger(draftClientId)
if NylasEnv.isMainWindow() if NylasEnv.isMainWindow()
@ -447,6 +375,9 @@ class DraftStore
@_notifyUserOfError({draftClientId, threadId, errorMessage}) @_notifyUserOfError({draftClientId, threadId, errorMessage})
, 100 , 100
_isPopout: ->
NylasEnv.getWindowType() is "composer"
_notifyUserOfError: ({draftClientId, threadId, errorMessage}) -> _notifyUserOfError: ({draftClientId, threadId, errorMessage}) ->
focusedThread = FocusedContentStore.focused('thread') focusedThread = FocusedContentStore.focused('thread')
if threadId and focusedThread?.id is threadId if threadId and focusedThread?.id is threadId

View file

@ -18,16 +18,16 @@ class TaskQueueStatusStore extends NylasStore
query = DatabaseStore.findJSONBlob(TaskQueue.JSONBlobStorageKey) query = DatabaseStore.findJSONBlob(TaskQueue.JSONBlobStorageKey)
Rx.Observable.fromQuery(query).subscribe (json) => Rx.Observable.fromQuery(query).subscribe (json) =>
@_queue = json || [] @_queue = json || []
@_waitingLocals = @_waitingLocals.filter ({taskId, resolve}) => @_waitingLocals = @_waitingLocals.filter ({task, resolve}) =>
task = _.findWhere(@_queue, {id: taskId}) queuedTask = _.findWhere(@_queue, {id: task.id})
if not task or task.queueState.localComplete if not queuedTask or queuedTask.queueState.localComplete
resolve() resolve(task)
return false return false
return true return true
@_waitingRemotes = @_waitingRemotes.filter ({taskId, resolve}) => @_waitingRemotes = @_waitingRemotes.filter ({task, resolve}) =>
task = _.findWhere(@_queue, {id: taskId}) queuedTask = _.findWhere(@_queue, {id: task.id})
if not task if not queuedTask
resolve() resolve(task)
return false return false
return true return true
@trigger() @trigger()
@ -37,11 +37,11 @@ class TaskQueueStatusStore extends NylasStore
waitForPerformLocal: (task) => waitForPerformLocal: (task) =>
new Promise (resolve, reject) => new Promise (resolve, reject) =>
@_waitingLocals.push({taskId: task.id, resolve: resolve}) @_waitingLocals.push({task, resolve})
waitForPerformRemote: (task) => waitForPerformRemote: (task) =>
new Promise (resolve, reject) => new Promise (resolve, reject) =>
@_waitingRemotes.push({taskId: task.id, resolve: resolve}) @_waitingRemotes.push({task, resolve})
tasksMatching: (type, matching = {}) -> tasksMatching: (type, matching = {}) ->
type = type.name unless _.isString(type) type = type.name unless _.isString(type)

View file

@ -24,11 +24,12 @@ try {
export default class SendDraftTask extends BaseDraftTask { export default class SendDraftTask extends BaseDraftTask {
constructor(draftClientId) { constructor(draftClientId, {playSound = true, emitError = true} = {}) {
super(draftClientId); super(draftClientId);
this.uploaded = [];
this.draft = null; this.draft = null;
this.message = null; this.message = null;
this.emitError = emitError
this.playSound = playSound
} }
label() { label() {
@ -183,7 +184,7 @@ export default class SendDraftTask extends BaseDraftTask {
NylasAPI.makeDraftDeletionRequest(this.draft); NylasAPI.makeDraftDeletionRequest(this.draft);
// Play the sending sound // Play the sending sound
if (NylasEnv.config.get("core.sending.sounds")) { if (this.playSound && NylasEnv.config.get("core.sending.sounds")) {
SoundRegistry.playSound('send'); SoundRegistry.playSound('send');
} }
@ -215,11 +216,13 @@ export default class SendDraftTask extends BaseDraftTask {
} }
} }
Actions.draftSendingFailed({ if (this.emitError) {
threadId: this.draft.threadId, Actions.sendDraftFailed({
draftClientId: this.draft.clientId, threadId: this.draft.threadId,
errorMessage: message, draftClientId: this.draft.clientId,
}); errorMessage: message,
});
}
NylasEnv.reportError(err); NylasEnv.reportError(err);
return Promise.resolve([Task.Status.Failed, err]); return Promise.resolve([Task.Status.Failed, err]);

View file

@ -14,10 +14,14 @@ export default class SyncbackMetadataTask extends SyncbackModelTask {
} }
getRequestData = (model) => { getRequestData = (model) => {
if (!model.serverId) {
throw new Error(`Can't syncback metadata for a ${this.modelClassName} instance that doesn't have a serverId`)
}
const metadata = model.metadataObjectForPluginId(this.pluginId); const metadata = model.metadataObjectForPluginId(this.pluginId);
return { return {
path: `/metadata/${model.id}?client_id=${this.pluginId}`, path: `/metadata/${model.serverId}?client_id=${this.pluginId}`,
method: 'POST', method: 'POST',
body: { body: {
object_id: model.serverId, object_id: model.serverId,

View file

@ -49,12 +49,15 @@ export default class SyncbackModelTask extends Task {
}; };
makeRequest = (model) => { makeRequest = (model) => {
const options = _.extend({ try {
accountId: model.accountId, const options = _.extend({
returnsModel: false, accountId: model.accountId,
}, this.getRequestData(model)); returnsModel: false,
}, this.getRequestData(model));
return NylasAPI.makeRequest(options); return NylasAPI.makeRequest(options);
} catch (error) {
return Promise.reject(error)
}
}; };
getRequestData = (model) => { getRequestData = (model) => {

View file

@ -89,6 +89,7 @@ class NylasExports
@lazyLoad "Task", 'flux/tasks/task' @lazyLoad "Task", 'flux/tasks/task'
@lazyLoad "TaskFactory", 'flux/tasks/task-factory' @lazyLoad "TaskFactory", 'flux/tasks/task-factory'
@lazyLoadAndRegisterTask "EventRSVPTask", 'event-rsvp-task' @lazyLoadAndRegisterTask "EventRSVPTask", 'event-rsvp-task'
@lazyLoadAndRegisterTask "BaseDraftTask", 'base-draft-task'
@lazyLoadAndRegisterTask "SendDraftTask", 'send-draft-task' @lazyLoadAndRegisterTask "SendDraftTask", 'send-draft-task'
@lazyLoadAndRegisterTask "MultiSendToIndividualTask", 'multi-send-to-individual-task' @lazyLoadAndRegisterTask "MultiSendToIndividualTask", 'multi-send-to-individual-task'
@lazyLoadAndRegisterTask "MultiSendSessionCloseTask", 'multi-send-session-close-task' @lazyLoadAndRegisterTask "MultiSendSessionCloseTask", 'multi-send-session-close-task'
@ -166,10 +167,11 @@ class NylasExports
@lazyLoad "CanvasUtils", 'canvas-utils' @lazyLoad "CanvasUtils", 'canvas-utils'
@lazyLoad "RegExpUtils", 'regexp-utils' @lazyLoad "RegExpUtils", 'regexp-utils'
@lazyLoad "MenuHelpers", 'menu-helpers' @lazyLoad "MenuHelpers", 'menu-helpers'
@lazyLoad "MessageUtils", 'flux/models/message-utils'
@lazyLoad "DeprecateUtils", 'deprecate-utils' @lazyLoad "DeprecateUtils", 'deprecate-utils'
@lazyLoad "VirtualDOMUtils", 'virtual-dom-utils' @lazyLoad "VirtualDOMUtils", 'virtual-dom-utils'
@lazyLoad "NylasSpellchecker", 'nylas-spellchecker' @lazyLoad "NylasSpellchecker", 'nylas-spellchecker'
@lazyLoad "DraftHelpers", 'flux/stores/draft-helpers'
@lazyLoad "MessageUtils", 'flux/models/message-utils'
@lazyLoad "EditorAPI", 'components/contenteditable/editor-api' @lazyLoad "EditorAPI", 'components/contenteditable/editor-api'
# Services # Services

@ -1 +1 @@
Subproject commit cfd64332c29ecfe965e2b556c77ba10c2a88dca5 Subproject commit dc09d78e0bad22597b5bba6511091102e26bbaef