From c3d8ab5ceb5e87ed36250709af1e1c9843c7cec3 Mon Sep 17 00:00:00 2001 From: Jackie Luo Date: Tue, 24 May 2016 11:22:47 -0700 Subject: [PATCH] feat(multi-send): Allow multi-send for non-Exchange accounts Summary: Updates the send task to use multi-send for emails with link/open tracking metadata sent via SMTP. Places an email without link/open tracking in the sent folder. TODO: Override send button (i.e., mail merge) and move all of the multi-send tasks to package. Test Plan: Tested locally. Reviewers: evan, bengotow, juan Reviewed By: bengotow, juan Differential Revision: https://phab.nylas.com/D2974 --- .../tasks/multi-send-session-close-task.es6 | 40 +++++ .../tasks/multi-send-to-individual-task.es6 | 57 +++++++ src/flux/tasks/send-draft-task.es6 | 141 ++++++++++++++---- src/global/nylas-exports.coffee | 2 + 4 files changed, 212 insertions(+), 28 deletions(-) create mode 100644 src/flux/tasks/multi-send-session-close-task.es6 create mode 100644 src/flux/tasks/multi-send-to-individual-task.es6 diff --git a/src/flux/tasks/multi-send-session-close-task.es6 b/src/flux/tasks/multi-send-session-close-task.es6 new file mode 100644 index 000000000..604c693ae --- /dev/null +++ b/src/flux/tasks/multi-send-session-close-task.es6 @@ -0,0 +1,40 @@ +import Task from './task'; +import {APIError} from '../errors'; +import NylasAPI from '../nylas-api'; +import MultiSendToIndividualTask from './multi-send-to-individual-task'; + + +export default class MultiSendSessionCloseTask extends Task { + constructor(opts = {}) { + super(opts); + this.message = opts.message; + } + + isDependentOnTask(other) { + return (other instanceof MultiSendToIndividualTask) && (other.message.clientId === this.message.clientId); + } + + performRemote() { + return NylasAPI.makeRequest({ + method: "DELETE", + path: `/send-multiple/${this.message.id}`, + accountId: this.message.accountId, + }) + .then(() => { + return Promise.resolve(Task.Status.Success); + }) + .catch((err) => { + const errorMessage = `We had trouble saving this message to your Sent folder.\n\n${err.message}`; + if (err instanceof APIError) { + if (NylasAPI.PermanentErrorCodes.includes(err.statusCode)) { + NylasEnv.showErrorDialog(errorMessage, {showInMainWindow: true}); + return Promise.resolve([Task.Status.Failed, err]); + } + return Promise.resolve(Task.Status.Retry); + } + NylasEnv.reportError(err); + NylasEnv.showErrorDialog(errorMessage, {showInMainWindow: true}); + return Promise.resolve([Task.Status.Failed, err]); + }); + } +} diff --git a/src/flux/tasks/multi-send-to-individual-task.es6 b/src/flux/tasks/multi-send-to-individual-task.es6 new file mode 100644 index 000000000..38bafdd6e --- /dev/null +++ b/src/flux/tasks/multi-send-to-individual-task.es6 @@ -0,0 +1,57 @@ +import Task from './task'; +import {APIError} from '../errors'; +import NylasAPI from '../nylas-api'; +import {RegExpUtils} from 'nylas-exports'; + + +export default class MultiSendToIndividualTask extends Task { + constructor(opts = {}) { + super(opts); + this.message = opts.message; + this.recipient = opts.recipient; + } + + performRemote() { + return NylasAPI.makeRequest({ + method: "POST", + path: `/send-multiple/${this.message.id}`, + accountId: this.message.accountId, + body: { + send_to: { + email: this.recipient.email, + name: this.recipient.name, + }, + body: this._customizeTrackingForRecipient(this.message.body), + }, + }) + .then(() => { + return Promise.resolve(Task.Status.Success); + }) + .catch((err) => { + const errorMessage = `We had trouble sending this message. ${this.recipient.displayName()} may not have received this email.\n\n${err.message}`; + if (err instanceof APIError) { + if (NylasAPI.PermanentErrorCodes.includes(err.statusCode)) { + NylasEnv.showErrorDialog(errorMessage, {showInMainWindow: true}); + return Promise.resolve([Task.Status.Failed, err]); + } + return Promise.resolve(Task.Status.Retry); + } + NylasEnv.reportError(err); + NylasEnv.showErrorDialog(errorMessage, {showInMainWindow: true}); + return Promise.resolve([Task.Status.Failed, err]); + }); + } + + _customizeTrackingForRecipient(text) { + const encodedEmail = btoa(this.recipient.email) + .replace(/\+/g, '-') + .replace(/\//g, '_'); + let body = text.replace(//g, (match, url) => { + return ``; + }); + body = body.replace(RegExpUtils.urlLinkTagRegex(), (match, prefix, url, suffix, content, closingTag) => { + return `${prefix}${url}&r=${encodedEmail}${suffix}${content}${closingTag}`; + }); + return body; + } +} diff --git a/src/flux/tasks/send-draft-task.es6 b/src/flux/tasks/send-draft-task.es6 index 6def05fff..c8f84cb9e 100644 --- a/src/flux/tasks/send-draft-task.es6 +++ b/src/flux/tasks/send-draft-task.es6 @@ -1,3 +1,5 @@ +/* eslint global-require: 0 */ +import {RegExpUtils} from 'nylas-exports'; import Task from './task'; import Actions from '../actions'; import Message from '../models/message'; @@ -7,8 +9,18 @@ import SoundRegistry from '../../sound-registry'; import DatabaseStore from '../stores/database-store'; import AccountStore from '../stores/account-store'; import BaseDraftTask from './base-draft-task'; +import MultiSendToIndividualTask from './multi-send-to-individual-task'; +import MultiSendSessionCloseTask from './multi-send-session-close-task'; import SyncbackMetadataTask from './syncback-metadata-task'; import NotifyPluginsOfSendTask from './notify-plugins-of-send-task'; +let OPEN_TRACKING_ID = null; +let LINK_TRACKING_ID = null; +try { + OPEN_TRACKING_ID = require('../../../internal_packages/open-tracking/lib/open-tracking-constants').PLUGIN_ID; + LINK_TRACKING_ID = require('../../../internal_packages/link-tracking/lib/link-tracking-constants').PLUGIN_ID; +} catch (err) { + console.log(err) +} export default class SendDraftTask extends BaseDraftTask { @@ -27,18 +39,6 @@ export default class SendDraftTask extends BaseDraftTask { return this.refreshDraftReference() .then(this.assertDraftValidity) .then(this.sendMessage) - .then((responseJSON) => { - this.message = new Message().fromJSON(responseJSON) - this.message.clientId = this.draft.clientId - this.message.draft = false - this.message.clonePluginMetadataFrom(this.draft) - - return DatabaseStore.inTransaction((t) => - this.refreshDraftReference().then(() => - t.persistModel(this.message) - ) - ); - }) .then(this.updatePluginMetadata) .then(this.onSuccess) .catch(this.onError); @@ -62,9 +62,55 @@ export default class SendDraftTask extends BaseDraftTask { return Promise.resolve(); } + sendMessage = () => { + if (OPEN_TRACKING_ID && LINK_TRACKING_ID && + (this.draft.metadataForPluginId(OPEN_TRACKING_ID) || + this.draft.metadataForPluginId(LINK_TRACKING_ID)) && + AccountStore.accountForId(this.draft.accountId).provider !== "eas") { + return this.sendWithMultipleBodies(); + } + return this.sendWithSingleBody(); + } + + sendWithMultipleBodies = () => { + const draft = this.draft.clone(); + draft.body = this.stripTrackingFromBody(draft.body); + return NylasAPI.makeRequest({ + path: "/send-multiple", + accountId: this.draft.accountId, + method: 'POST', + body: draft.toJSON(), + timeout: 1000 * 60 * 5, // We cannot hang up a send - won't know if it sent + returnsModel: false, + }) + .catch((err) => { + this.onSendError(err, this.sendWithMultipleBodies); + }) + .then((responseJSON) => { + return this.createMessageFromResponse(responseJSON); + }) + .then(() => { + const recipients = this.message.to.concat(this.message.cc, this.message.bcc); + recipients.forEach((recipient) => { + const t1 = new MultiSendToIndividualTask({ + message: this.message, + recipient: recipient, + }); + Actions.queueTask(t1); + }); + const t2 = new MultiSendSessionCloseTask({ + message: this.message, + }); + Actions.queueTask(t2); + }) + .catch((err) => { + return Promise.reject(err); + }); + } + // This function returns a promise that resolves to the draft when the draft has // been sent successfully. - sendMessage = () => { + sendWithSingleBody = () => { return NylasAPI.makeRequest({ path: "/send", accountId: this.draft.accountId, @@ -74,20 +120,13 @@ export default class SendDraftTask extends BaseDraftTask { returnsModel: false, }) .catch((err) => { - // If the message you're "replying to" were deleted - if (err.message && err.message.indexOf('Invalid message public id') === 0) { - this.draft.replyToMessageId = null - return this.sendMessage() - } - - // If the thread was deleted - if (err.message && err.message.indexOf('Invalid thread') === 0) { - this.draft.threadId = null; - this.draft.replyToMessageId = null; - return this.sendMessage(); - } - - return Promise.reject(err) + this.onSendError(err, this.sendWithSingleBody); + }) + .then((responseJSON) => { + return this.createMessageFromResponse(responseJSON); + }) + .catch((err) => { + return Promise.reject(err); }); } @@ -107,7 +146,36 @@ export default class SendDraftTask extends BaseDraftTask { Actions.queueTask(t2); } - return Promise.resolve() + return Promise.resolve(); + } + + createMessageFromResponse = (responseJSON) => { + this.message = new Message().fromJSON(responseJSON); + this.message.clientId = this.draft.clientId; + this.message.body = this.draft.body; + this.message.draft = false; + this.message.clonePluginMetadataFrom(this.draft); + + return DatabaseStore.inTransaction((t) => + this.refreshDraftReference().then(() => { + return t.persistModel(this.message); + }) + ); + } + + stripTrackingFromBody(text) { + let body = text.replace(//g, () => { + return ""; + }); + body = body.replace(RegExpUtils.urlLinkTagRegex(), (match, prefix, url, suffix, content, closingTag) => { + const param = url.split("?")[1]; + if (param) { + const link = decodeURIComponent(param.split("=")[1]); + return `${prefix}${link}${suffix}${content}${closingTag}`; + } + return match; + }); + return body; } onSuccess = () => { @@ -156,4 +224,21 @@ export default class SendDraftTask extends BaseDraftTask { return Promise.resolve([Task.Status.Failed, err]); } + + onSendError = (err, retrySend) => { + // If the message you're "replying to" has been deleted + if (err.message && err.message.indexOf('Invalid message public id') === 0) { + this.draft.replyToMessageId = null; + return retrySend(); + } + + // If the thread has been deleted + if (err.message && err.message.indexOf('Invalid thread') === 0) { + this.draft.threadId = null; + this.draft.replyToMessageId = null; + return retrySend(); + } + + return Promise.reject(err); + } } diff --git a/src/global/nylas-exports.coffee b/src/global/nylas-exports.coffee index 5bd145de7..7b2522ade 100644 --- a/src/global/nylas-exports.coffee +++ b/src/global/nylas-exports.coffee @@ -90,6 +90,8 @@ class NylasExports @lazyLoad "TaskFactory", 'flux/tasks/task-factory' @lazyLoadAndRegisterTask "EventRSVPTask", 'event-rsvp-task' @lazyLoadAndRegisterTask "SendDraftTask", 'send-draft-task' + @lazyLoadAndRegisterTask "MultiSendToIndividualTask", 'multi-send-to-individual-task' + @lazyLoadAndRegisterTask "MultiSendSessionCloseTask", 'multi-send-session-close-task' @lazyLoadAndRegisterTask "ChangeMailTask", 'change-mail-task' @lazyLoadAndRegisterTask "DestroyDraftTask", 'destroy-draft-task' @lazyLoadAndRegisterTask "ChangeLabelsTask", 'change-labels-task'