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 ReactDOM from 'react-dom';
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 CollapsedParticipants from './collapsed-participants';
@ -105,7 +105,7 @@ export default class ComposerHeader extends React.Component {
if (_.isEmpty(this.props.draft.subject)) {
return true;
}
if (Utils.isForwardedMessage(this.props.draft)) {
if (DraftHelpers.isForwardedMessage(this.props.draft)) {
return true;
}
if (this.props.draft.replyToMessageId) {
@ -153,7 +153,7 @@ export default class ComposerHeader extends React.Component {
if (isDropping) {
this.setState({
participantsFocused: true,
enabledFields: [...Fields.ParticipantFields, Fields.Subject],
enabledFields: [...Fields.ParticipantFields, Fields.From, Fields.Subject],
})
}
}

View file

@ -7,10 +7,10 @@ import {
Utils,
Actions,
DraftStore,
ContactStore,
QuotedHTMLTransformer,
UndoManager,
DraftHelpers,
FileDownloadStore,
ExtensionRegistry,
QuotedHTMLTransformer,
} from 'nylas-exports';
import {
@ -55,7 +55,7 @@ export default class ComposerView extends React.Component {
constructor(props) {
super(props)
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) {
if (newProps.session !== this.props.session) {
componentWillReceiveProps(nextProps) {
if (nextProps.session !== this.props.session) {
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({
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 {session} = this.props
const {errors, warnings} = session.validateDraftForSending()
const {to, cc, bcc, body, files, uploads} = this.props.draft;
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) {
if (errors.length > 0) {
dialog.showMessageBox(remote.getCurrentWindow(), {
type: 'warning',
buttons: ['Edit Message', 'Cancel'],
message: 'Cannot Send',
detail: dealbreaker,
detail: errors[0],
});
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)) {
const response = dialog.showMessageBox(remote.getCurrentWindow(), {
type: 'warning',
@ -569,7 +532,6 @@ export default class ComposerView extends React.Component {
}
return false;
}
return true;
}
@ -585,15 +547,62 @@ export default class ComposerView extends React.Component {
Actions.selectAttachment({messageClientId: this.props.draft.clientId});
}
_mentionsAttachment = (body) => {
let cleaned = QuotedHTMLTransformer.removeQuotedHTML(body.toLowerCase().trim());
const signatureIndex = cleaned.indexOf('<signature>');
if (signatureIndex !== -1) {
cleaned = cleaned.substr(0, signatureIndex - 1);
undo = (event) => {
event.preventDefault();
event.stopPropagation();
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() {
const dropCoverDisplay = this.state.isDropping ? 'block' : 'none';

View file

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

View file

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

View file

@ -1,7 +1,7 @@
_ = require 'underscore'
React = require 'react'
{Actions,
Utils,
{DraftHelpers,
Actions,
Thread,
ChangeStarredTask,
AccountStore} = require 'nylas-exports'
@ -25,7 +25,7 @@ class ThreadListIcon extends React.Component
last = msgs[msgs.length - 1]
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'
else
return 'thread-icon-replied thread-icon-star-on-hover'

View file

@ -154,3 +154,24 @@ describe "Contact", ->
spyOn(AccountStore, 'accountForEmail').andReturn(acct)
expect(c1.isMe()).toBe(true)
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
describe 'isValidContact', ->
it "should return true for a variety of valid contacts", ->
expect(ContactStore.isValidContact(new Contact(name: 'Ben', email: 'ben@nylas.com'))).toBe(true)
expect(ContactStore.isValidContact(new Contact(email: 'ben@nylas.com'))).toBe(true)
expect(ContactStore.isValidContact(new Contact(email: 'ben+123@nylas.com'))).toBe(true)
it "should call contact.isValid", ->
contact = new Contact()
spyOn(contact, 'isValid').andReturn(true)
expect(ContactStore.isValidContact(contact)).toBe(true)
it "should return false for non-Contact objects", ->
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", ->
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', ->
testCases =
# 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,
Account,
DraftStore,
DraftHelpers,
DatabaseStore,
SoundRegistry,
SendDraftTask,
DestroyDraftTask,
ComposerExtension,
ExtensionRegistry,
FocusedContentStore,
DatabaseTransaction,
SyncbackDraftFilesTask,
} from 'nylas-exports';
import {remote} from 'electron';
@ -323,7 +322,7 @@ describe('DraftStore', function draftStore() {
DraftStore._draftSessions[this.draft.clientId] = session;
spyOn(DraftStore, "_doneWithSession").andCallThrough();
spyOn(DraftStore, "_prepareForSyncback").andReturn(Promise.resolve());
spyOn(DraftHelpers, "prepareDraftForSyncback").andReturn(Promise.resolve());
spyOn(DraftStore, "trigger");
spyOn(SoundRegistry, "playSound");
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", () => {
spyOn(NylasEnv, "isMainWindow").andReturn(false);
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.trigger).toHaveBeenCalledWith(this.draft.clientId);
});
@ -437,7 +421,7 @@ describe('DraftStore', function draftStore() {
spyOn(remote.dialog, "showMessageBox");
spyOn(Actions, "composePopoutDraft");
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);
expect(DraftStore.isSendingDraft(this.draft.clientId)).toBe(false);
expect(DraftStore.trigger).toHaveBeenCalledWith(this.draft.clientId);
@ -452,7 +436,7 @@ describe('DraftStore', function draftStore() {
spyOn(FocusedContentStore, "focused").andReturn({id: "t1"});
spyOn(Actions, "composePopoutDraft");
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);
expect(Actions.composePopoutDraft).toHaveBeenCalled();
const call = Actions.composePopoutDraft.calls[0];
@ -465,7 +449,7 @@ describe('DraftStore', function draftStore() {
spyOn(Actions, "composePopoutDraft");
DraftStore._draftsSending[this.draft.clientId] = true;
spyOn(FocusedContentStore, "focused").andReturn(null);
Actions.draftSendingFailed({errorMessage: "boohoo", draftClientId: this.draft.clientId});
Actions.sendDraftFailed({errorMessage: "boohoo", draftClientId: this.draft.clientId});
advanceClock(200);
expect(Actions.composePopoutDraft).toHaveBeenCalled();
const call = Actions.composePopoutDraft.calls[0];

View file

@ -222,7 +222,7 @@ describe('SendDraftTask', function sendDraftTask() {
describe("when there are errors", () => {
beforeEach(() => {
spyOn(Actions, 'draftSendingFailed');
spyOn(Actions, 'sendDraftFailed');
jasmine.unspy(NylasAPI, "makeRequest");
});
@ -238,7 +238,7 @@ describe('SendDraftTask', function sendDraftTask() {
waitsForPromise(() => this.task.performRemote().then((status) => {
expect(status[0]).toBe(Task.Status.Failed);
expect(status[1]).toBe(thrownError);
expect(Actions.draftSendingFailed).toHaveBeenCalled();
expect(Actions.sendDraftFailed).toHaveBeenCalled();
expect(NylasEnv.reportError).toHaveBeenCalled();
}));
});
@ -300,7 +300,7 @@ describe('SendDraftTask', function sendDraftTask() {
waitsForPromise(() => this.task.performRemote().then((status) => {
expect(status[0]).toBe(Task.Status.Failed);
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) => {
expect(status[0]).toBe(Task.Status.Failed);
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) => {
expect(status[0]).toBe(Task.Status.Failed);
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));
}));
});
@ -365,9 +365,9 @@ describe('SendDraftTask', function sendDraftTask() {
waitsForPromise(() => this.task.performRemote().then((status) => {
expect(status[0]).toBe(Task.Status.Failed);
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));
}));
});

View file

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

View file

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

View file

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

View file

@ -91,7 +91,17 @@ class Contact extends Model
json['name'] ||= json['email']
json
# Public: Returns true if the contact provided is a {Contact} instance and
# contains a properly formatted email address.
#
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
# 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 Utils from './utils'
import Event from './event'
import Category from './category'
import Contact from './contact'
import Category from './category'
import Attributes from '../attributes'
import ModelWithMetadata from './model-with-metadata'
/**
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

View file

@ -223,25 +223,6 @@ Utils =
else
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
emailsHaveSameDomain: (args...) ->
return false if args.length < 2

View file

@ -80,18 +80,9 @@ class ContactStore extends NylasStore
return Promise.resolve(results)
# Public: Returns true if the contact provided is a {Contact} instance and
# contains a properly formatted email address.
#
isValidContact: (contact) =>
return false unless contact instanceof Contact
return false unless contact.email
# 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
return contact.isValid()
parseContactsInString: (contactString, options={}) =>
{skipNameLookup} = options

View file

@ -1,13 +1,16 @@
Message = require('../models/message').default
Actions = require '../actions'
NylasAPI = require '../nylas-api'
AccountStore = require './account-store'
ContactStore = require './contact-store'
DatabaseStore = require './database-store'
UndoStack = require '../../undo-stack'
ExtensionRegistry = require('../../extension-registry')
DraftHelpers = require '../stores/draft-helpers'
ExtensionRegistry = require '../../extension-registry'
{Listener, Publisher} = require '../modules/reflux-coffee'
SyncbackDraftTask = require('../tasks/syncback-draft-task').default
CoffeeHelpers = require '../coffee-helpers'
DraftStore = null
_ = require 'underscore'
MetadataChangePrefix = 'metadata.'
@ -143,6 +146,64 @@ class DraftEditingSession
@changes.teardown()
@_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) ->
if !draft.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'
DraftEditingSession = require './draft-editing-session'
DraftHelpers = require './draft-helpers'
DraftFactory = require './draft-factory'
DatabaseStore = require './database-store'
AccountStore = require './account-store'
@ -17,7 +18,6 @@ SyncbackDraftTask = require('../tasks/syncback-draft-task').default
DestroyDraftTask = require('../tasks/destroy-draft-task').default
Thread = require('../models/thread').default
Contact = require '../models/contact'
Message = require('../models/message').default
Actions = require '../actions'
@ -55,7 +55,7 @@ class DraftStore
@listenTo Actions.sendDraftSuccess, => @trigger()
@listenTo Actions.composePopoutDraft, @_onPopoutDraftClientId
@listenTo Actions.composeNewBlankDraft, @_onPopoutBlankDraft
@listenTo Actions.draftSendingFailed, @_onDraftSendingFailed
@listenTo Actions.sendDraftFailed, @_onSendDraftFailed
@listenTo Actions.sendQuickReply, @_onSendQuickReply
if NylasEnv.isMainWindow()
@ -66,7 +66,6 @@ class DraftStore
# window.
@listenTo Actions.ensureDraftSynced, @_onEnsureDraftSynced
@listenTo Actions.sendDraft, @_onSendDraft
@listenTo Actions.sendDrafts, @_onSendDrafts
@listenTo Actions.destroyDraft, @_onDestroyDraft
@listenTo Actions.removeFile, @_onRemoveFile
@ -335,92 +334,21 @@ class DraftStore
_onEnsureDraftSynced: (draftClientId) =>
@sessionForClientId(draftClientId).then (session) =>
@_prepareForSyncback(session).then =>
@_queueDraftAssetTasks(session.draft())
DraftHelpers.prepareDraftForSyncback(session)
.then (preparedDraft) =>
Actions.queueTask(new SyncbackDraftTask(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
@sessionForClientId(draftClientId).then (session) =>
@_prepareForSyncback(session).then =>
if NylasEnv.config.get("core.sending.sounds")
SoundRegistry.playSound('hit-send')
@_queueDraftAssetTasks(session.draft())
DraftHelpers.prepareDraftForSyncback(session)
.then (preparedDraft) =>
Actions.queueTask(new SendDraftTask(draftClientId))
@_doneWithSession(session)
Promise.resolve()
_queueDraftAssetTasks: (draft) =>
if draft.files.length > 0 or draft.uploads.length > 0
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)
if NylasEnv.config.get("core.sending.sounds")
SoundRegistry.playSound('hit-send')
if @_isPopout()
NylasEnv.close()
__testExtensionTransforms: ->
clientId = NylasEnv.getWindowProps().draftClientId
@ -436,7 +364,7 @@ class DraftStore
session.changes.add({files})
session.changes.commit()
_onDraftSendingFailed: ({draftClientId, threadId, errorMessage}) ->
_onSendDraftFailed: ({draftClientId, threadId, errorMessage}) ->
@_draftsSending[draftClientId] = false
@trigger(draftClientId)
if NylasEnv.isMainWindow()
@ -447,6 +375,9 @@ class DraftStore
@_notifyUserOfError({draftClientId, threadId, errorMessage})
, 100
_isPopout: ->
NylasEnv.getWindowType() is "composer"
_notifyUserOfError: ({draftClientId, threadId, errorMessage}) ->
focusedThread = FocusedContentStore.focused('thread')
if threadId and focusedThread?.id is threadId

View file

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

View file

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

View file

@ -14,10 +14,14 @@ export default class SyncbackMetadataTask extends SyncbackModelTask {
}
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);
return {
path: `/metadata/${model.id}?client_id=${this.pluginId}`,
path: `/metadata/${model.serverId}?client_id=${this.pluginId}`,
method: 'POST',
body: {
object_id: model.serverId,

View file

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

View file

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

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