import _ from 'underscore'; import { File, Actions, Thread, Contact, Message, DraftStore, AccountStore, DatabaseStore, DatabaseTransaction, SanitizeTransformer, InlineStyleTransformer, } from 'nylas-exports'; import DraftFactory from '../../src/flux/stores/draft-factory'; let msgFromMe = null; let fakeThread = null; let fakeMessage1 = null; let fakeMessage2 = null; let msgWithReplyTo = null; let fakeMessageWithFiles = null; let msgWithReplyToDuplicates = null; let account = null; describe("DraftFactory", () => { beforeEach(() => { // Out of the scope of these specs spyOn(InlineStyleTransformer, 'run').andCallFake((input) => Promise.resolve(input)); spyOn(SanitizeTransformer, 'run').andCallFake((input) => Promise.resolve(input)); account = AccountStore.accounts()[0]; fakeThread = new Thread({ id: 'fake-thread-id', accountId: account.id, subject: 'Fake Subject', }); fakeMessage1 = new Message({ id: 'fake-message-1', accountId: account.id, to: [new Contact({email: 'ben@nylas.com'}), new Contact({email: 'evan@nylas.com'})], cc: [new Contact({email: 'mg@nylas.com'}), account.me()], bcc: [new Contact({email: 'recruiting@nylas.com'})], from: [new Contact({email: 'customer@example.com', name: 'Customer'})], threadId: 'fake-thread-id', body: 'Fake Message 1', subject: 'Fake Subject', date: new Date(1415814587), }); fakeMessage2 = new Message({ id: 'fake-message-2', accountId: account.id, to: [new Contact({email: 'customer@example.com'})], from: [new Contact({email: 'ben@nylas.com'})], threadId: 'fake-thread-id', body: 'Fake Message 2', subject: 'Re: Fake Subject', date: new Date(1415814587), }); fakeMessageWithFiles = new Message({ id: 'fake-message-with-files', accountId: account.id, to: [new Contact({email: 'ben@nylas.com'}), new Contact({email: 'evan@nylas.com'})], cc: [new Contact({email: 'mg@nylas.com'}), account.me()], bcc: [new Contact({email: 'recruiting@nylas.com'})], from: [new Contact({email: 'customer@example.com', name: 'Customer'})], files: [new File({filename: "test.jpg"}), new File({filename: "test.pdj"})], threadId: 'fake-thread-id', body: 'Fake Message 1', subject: 'Fake Subject', date: new Date(1415814587), }); msgFromMe = new Message({ id: 'fake-message-3', accountId: account.id, to: [new Contact({email: '1@1.com'}), new Contact({email: '2@2.com'})], cc: [new Contact({email: '3@3.com'}), new Contact({email: '4@4.com'})], bcc: [new Contact({email: '5@5.com'}), new Contact({email: '6@6.com'})], from: [account.me()], threadId: 'fake-thread-id', body: 'Fake Message 2', subject: 'Re: Fake Subject', date: new Date(1415814587), }); msgWithReplyTo = new Message({ id: 'fake-message-reply-to', accountId: account.id, to: [new Contact({email: '1@1.com'}), new Contact({email: '2@2.com'})], cc: [new Contact({email: '3@3.com'}), new Contact({email: '4@4.com'})], bcc: [new Contact({email: '5@5.com'}), new Contact({email: '6@6.com'})], replyTo: [new Contact({email: 'reply-to@5.com'}), new Contact({email: 'reply-to@6.com'})], from: [new Contact({email: 'from@5.com'})], threadId: 'fake-thread-id', body: 'Fake Message 2', subject: 'Re: Fake Subject', date: new Date(1415814587), }); msgWithReplyToDuplicates = new Message({ id: 'fake-message-reply-to-duplicates', accountId: account.id, to: [new Contact({email: '1@1.com'}), new Contact({email: '2@2.com'})], cc: [new Contact({email: '1@1.com'}), new Contact({email: '4@4.com'})], from: [new Contact({email: 'reply-to@5.com'})], replyTo: [new Contact({email: 'reply-to@5.com'})], threadId: 'fake-thread-id', body: 'Fake Message Duplicates', subject: 'Re: Fake Subject', date: new Date(1415814587), }); }); describe("creating drafts", () => { describe("createDraftForReply", () => { it("should include quoted text", () => { waitsForPromise(() => { return DraftFactory.createDraftForReply({thread: fakeThread, message: fakeMessage1, type: 'reply'}).then((draft) => { expect(draft.body.indexOf('blockquote') > 0).toBe(true); expect(draft.body.indexOf(fakeMessage1.body) > 0).toBe(true); expect(draft.body.indexOf('gmail_quote') > 0).toBe(true); expect(draft.body.search(/On .+, at .+, Customer <customer@example.com> wrote/) > 0).toBe(true); }); }); }); it("should address the message to the previous message's sender", () => { waitsForPromise(() => { return DraftFactory.createDraftForReply({thread: fakeThread, message: fakeMessage1, type: 'reply'}).then((draft) => { expect(draft.to).toEqual(fakeMessage1.from); }); }); }); it("should set the replyToMessageId to the previous message's ids", () => { waitsForPromise(() => { return DraftFactory.createDraftForReply({thread: fakeThread, message: fakeMessage1, type: 'reply'}).then((draft) => { expect(draft.replyToMessageId).toEqual(fakeMessage1.id); }); }); }); it("should sanitize the HTML", () => { waitsForPromise(() => { return DraftFactory.createDraftForReply({thread: fakeThread, message: fakeMessage1, type: 'reply'}).then(() => { expect(InlineStyleTransformer.run).toHaveBeenCalled(); expect(SanitizeTransformer.run).toHaveBeenCalled(); }); }); }); it("should make the subject the subject of the message, not the thread", () => { fakeMessage1.subject = "OLD SUBJECT"; waitsForPromise(() => { return DraftFactory.createDraftForReply({thread: fakeThread, message: fakeMessage1, type: 'reply'}).then((draft) => { expect(draft.subject).toEqual("Re: OLD SUBJECT"); }); }); }); it("should change the subject from Fwd: back to Re: if necessary", () => { fakeMessage1.subject = "Fwd: This is my DRAFT"; waitsForPromise(() => { return DraftFactory.createDraftForReply({thread: fakeThread, message: fakeMessage1, type: 'reply'}).then((draft) => { expect(draft.subject).toEqual("Re: This is my DRAFT"); }); }); }); it("should only include the sender's name if it was available", () => { waitsForPromise(() => { return DraftFactory.createDraftForReply({thread: fakeThread, message: fakeMessage2, type: 'reply'}).then((draft) => { expect(draft.body.search(/On .+, at .+, ben@nylas.com wrote:/) > 0).toBe(true); }); }); }); }); describe("type: reply", () => { describe("when the message provided as context has one or more 'ReplyTo' recipients", () => { it("addresses the draft to all of the message's 'ReplyTo' recipients", () => { waitsForPromise(() => { return DraftFactory.createDraftForReply({thread: fakeThread, message: msgWithReplyTo, type: 'reply'}).then((draft) => { expect(draft.to).toEqual(msgWithReplyTo.replyTo); expect(draft.cc.length).toBe(0); expect(draft.bcc.length).toBe(0); }); }); }); }); describe("when the message provided as context was sent by the current user", () => { it("addresses the draft to all of the last messages's 'To' recipients", () => { waitsForPromise(() => { return DraftFactory.createDraftForReply({thread: fakeThread, message: msgFromMe, type: 'reply'}).then((draft) => { expect(draft.to).toEqual(msgFromMe.to); expect(draft.cc.length).toBe(0); expect(draft.bcc.length).toBe(0); }); }); }); }); }); describe("type: reply-all", () => { it("should include people in the cc field", () => { waitsForPromise(() => { return DraftFactory.createDraftForReply({thread: fakeThread, message: fakeMessage1, type: 'reply-all'}).then((draft) => { const ccEmails = draft.cc.map(cc => cc.email); expect(ccEmails.sort()).toEqual([ 'ben@nylas.com', 'evan@nylas.com', 'mg@nylas.com']); }); }); }); it("should not include people who were bcc'd on the previous message", () => { waitsForPromise(() => { return DraftFactory.createDraftForReply({thread: fakeThread, message: fakeMessage1, type: 'reply-all'}).then((draft) => { expect(draft.bcc).toEqual([]); expect(draft.cc.indexOf(fakeMessage1.bcc[0])).toEqual(-1); }); }); }); it("should not include you when you were cc'd on the previous message", () => { waitsForPromise(() => { return DraftFactory.createDraftForReply({thread: fakeThread, message: fakeMessage1, type: 'reply-all'}).then((draft) => { const ccEmails = draft.cc.map(cc => cc.email); expect(ccEmails.indexOf(account.me().email)).toEqual(-1); }); }); }); describe("when the message provided as context has one or more 'ReplyTo' recipients", () => { it("addresses the draft to all of the message's 'ReplyTo' recipients", () => { waitsForPromise(() => { return DraftFactory.createDraftForReply({thread: fakeThread, message: msgWithReplyTo, type: 'reply-all'}).then((draft) => { expect(draft.to).toEqual(msgWithReplyTo.replyTo); }); }); }); it("should not include the message's 'From' recipient in any field", () => { waitsForPromise(() => { return DraftFactory.createDraftForReply({thread: fakeThread, message: msgWithReplyTo, type: 'reply-all'}).then((draft) => { const all = [].concat(draft.to, draft.cc, draft.bcc); const match = _.find(all, (c) => c.email === msgWithReplyTo.from[0].email); expect(match).toEqual(undefined); }); }); }); }); describe("when the message provided has one or more 'ReplyTo' recipients and duplicates in the To/Cc fields", () => { it("should unique the to/cc fields", () => { waitsForPromise(() => { return DraftFactory.createDraftForReply({thread: fakeThread, message: msgWithReplyToDuplicates, type: 'reply-all'}).then((draft) => { const ccEmails = draft.cc.map(cc => cc.email); expect(ccEmails.sort()).toEqual(['1@1.com', '2@2.com', '4@4.com']); const toEmails = draft.to.map(to => to.email); expect(toEmails.sort()).toEqual(['reply-to@5.com']); }); }); }); }); describe("when the message provided as context was sent by the current user", () => { it("addresses the draft to all of the last messages's recipients", () => { waitsForPromise(() => { return DraftFactory.createDraftForReply({thread: fakeThread, message: msgFromMe, type: 'reply-all'}).then((draft) => { expect(draft.to).toEqual(msgFromMe.to); expect(draft.cc).toEqual(msgFromMe.cc); expect(draft.bcc.length).toBe(0); }); }); }); }); }); describe("onComposeForward", () => { beforeEach(() => { waitsForPromise(() => { return DraftFactory.createDraftForForward({thread: fakeThread, message: fakeMessage2}).then((draft) => { this.model = draft; }); }); }); it("should include forwarded message text, in a div rather than a blockquote", () => { expect(this.model.body.indexOf('gmail_quote') > 0).toBe(true); expect(this.model.body.indexOf('blockquote') > 0).toBe(false); expect(this.model.body.indexOf(fakeMessage2.body) > 0).toBe(true); expect(this.model.body.indexOf('---------- Forwarded message ---------') > 0).toBe(true); expect(this.model.body.indexOf('From: ben@nylas.com') > 0).toBe(true); expect(this.model.body.indexOf('Subject: Re: Fake Subject') > 0).toBe(true); expect(this.model.body.indexOf('To: customer@example.com') > 0).toBe(true); }); it("should not address the message to anyone", () => { expect(this.model.to).toEqual([]); expect(this.model.cc).toEqual([]); expect(this.model.bcc).toEqual([]); }); it("should not set the replyToMessageId", () => { expect(this.model.replyToMessageId).toEqual(undefined); }); it("should sanitize the HTML", () => { expect(InlineStyleTransformer.run).toHaveBeenCalled(); expect(SanitizeTransformer.run).toHaveBeenCalled(); }); it("should include the attached files", () => { waitsForPromise(() => { return DraftFactory.createDraftForForward({thread: fakeThread, message: fakeMessageWithFiles}).then((draft) => { expect(draft.files.length).toBe(2); expect(draft.files[0].filename).toBe("test.jpg"); }); }); }); it("should make the subject the subject of the message, not the thread", () => { fakeMessage1.subject = "OLD SUBJECT"; waitsForPromise(() => { return DraftFactory.createDraftForForward({thread: fakeThread, message: fakeMessage1}).then((draft) => { expect(draft.subject).toEqual("Fwd: OLD SUBJECT"); }); }); }); it("should change the subject from Re: back to Fwd: if necessary", () => { fakeMessage1.subject = "Re: This is my DRAFT"; waitsForPromise(() => { return DraftFactory.createDraftForForward({thread: fakeThread, message: fakeMessage1}).then((draft) => { expect(draft.subject).toEqual("Fwd: This is my DRAFT"); }); }); }); }); }); describe("createOrUpdateDraftForReply", () => { it("should throw an exception unless you provide `reply` or `reply-all`", () => { expect(() => DraftFactory.createOrUpdateDraftForReply({thread: fakeThread, message: fakeMessage1, type: 'wrong'}) ).toThrow(); }); describe("when there is already a draft in reply to the same message the thread", () => { beforeEach(() => { this.existingDraft = new Message({ clientId: 'asd', accountId: TEST_ACCOUNT_ID, replyToMessageId: fakeMessage1.id, threadId: fakeMessage1.threadId, draft: true, }); this.sessionStub = { changes: { add: jasmine.createSpy('add'), }, }; spyOn(Actions, 'focusDraft') spyOn(DatabaseStore, 'run').andReturn(Promise.resolve([fakeMessage1, this.existingDraft])); spyOn(DraftStore, 'sessionForClientId').andReturn(Promise.resolve(this.sessionStub)); spyOn(DatabaseTransaction.prototype, 'persistModel').andReturn(Promise.resolve()); this.expectContactsEqual = (a, b) => { expect(a.map(c => c.email).sort()).toEqual(b.map(c => c.email).sort()); } }); describe("when reply-all is passed", () => { it("should add missing participants", () => { this.existingDraft.to = fakeMessage1.participantsForReply().to; this.existingDraft.cc = fakeMessage1.participantsForReply().cc; DraftFactory.createOrUpdateDraftForReply({thread: fakeThread, message: fakeMessage1, type: 'reply-all', behavior: 'prefer-existing'}) advanceClock(); advanceClock(); const {to, cc} = DatabaseTransaction.prototype.persistModel.mostRecentCall.args[0]; this.expectContactsEqual(to, fakeMessage1.participantsForReplyAll().to); this.expectContactsEqual(cc, fakeMessage1.participantsForReplyAll().cc); }); it("should not blow away other participants who have been added to the draft", () => { const randomA = new Contact({email: 'other-guy-a@gmail.com'}); const randomB = new Contact({email: 'other-guy-b@gmail.com'}); this.existingDraft.to = fakeMessage1.participantsForReply().to.concat([randomA]); this.existingDraft.cc = fakeMessage1.participantsForReply().cc.concat([randomB]); DraftFactory.createOrUpdateDraftForReply({thread: fakeThread, message: fakeMessage1, type: 'reply-all', behavior: 'prefer-existing'}) advanceClock(); advanceClock(); const {to, cc} = DatabaseTransaction.prototype.persistModel.mostRecentCall.args[0]; this.expectContactsEqual(to, fakeMessage1.participantsForReplyAll().to.concat([randomA])); this.expectContactsEqual(cc, fakeMessage1.participantsForReplyAll().cc.concat([randomB])); }); }); describe("when reply is passed", () => { it("should remove participants present in the reply-all participant set and not in the reply set", () => { this.existingDraft.to = fakeMessage1.participantsForReplyAll().to; this.existingDraft.cc = fakeMessage1.participantsForReplyAll().cc; DraftFactory.createOrUpdateDraftForReply({thread: fakeThread, message: fakeMessage1, type: 'reply', behavior: 'prefer-existing'}) advanceClock(); advanceClock(); const {to, cc} = DatabaseTransaction.prototype.persistModel.mostRecentCall.args[0]; this.expectContactsEqual(to, fakeMessage1.participantsForReply().to); this.expectContactsEqual(cc, fakeMessage1.participantsForReply().cc); }); it("should not blow away other participants who have been added to the draft", () => { const randomA = new Contact({email: 'other-guy-a@gmail.com'}); const randomB = new Contact({email: 'other-guy-b@gmail.com'}); this.existingDraft.to = fakeMessage1.participantsForReplyAll().to.concat([randomA]); this.existingDraft.cc = fakeMessage1.participantsForReplyAll().cc.concat([randomB]); DraftFactory.createOrUpdateDraftForReply({thread: fakeThread, message: fakeMessage1, type: 'reply', behavior: 'prefer-existing'}) advanceClock(); advanceClock(); const {to, cc} = DatabaseTransaction.prototype.persistModel.mostRecentCall.args[0]; this.expectContactsEqual(to, fakeMessage1.participantsForReply().to.concat([randomA])); this.expectContactsEqual(cc, fakeMessage1.participantsForReply().cc.concat([randomB])); }); }); }); describe("when there is not an existing draft at the bottom of the thread", () => { beforeEach(() => { spyOn(Actions, 'focusDraft'); spyOn(DatabaseStore, 'run').andCallFake(() => [fakeMessage1]); spyOn(DraftFactory, 'createDraftForReply'); }); it("should call through to createDraftForReply", () => { DraftFactory.createOrUpdateDraftForReply({thread: fakeThread, message: fakeMessage1, type: 'reply-all'}) advanceClock(); advanceClock(); expect(DraftFactory.createDraftForReply).toHaveBeenCalledWith({thread: fakeThread, message: fakeMessage1, type: 'reply-all'}) DraftFactory.createOrUpdateDraftForReply({thread: fakeThread, message: fakeMessage1, type: 'reply'}) advanceClock(); advanceClock(); expect(DraftFactory.createDraftForReply).toHaveBeenCalledWith({thread: fakeThread, message: fakeMessage1, type: 'reply'}) }); }); }); describe("_prepareBodyForQuoting", () => { it("should transform inline styles and sanitize unsafe html", () => { const input = "test 123"; DraftFactory._prepareBodyForQuoting(input); expect(InlineStyleTransformer.run).toHaveBeenCalledWith(input); advanceClock(); expect(SanitizeTransformer.run).toHaveBeenCalledWith(input, SanitizeTransformer.Preset.UnsafeOnly); }); }); describe("createDraftForMailto", () => { describe("parameters in the URL", () => { beforeEach(() => { this.expected = "EmailSubjectLOLOL"; }); it("works for lowercase", () => { waitsForPromise(() => { return DraftFactory.createDraftForMailto('mailto:asdf@asdf.com?subject=' + this.expected).then((draft) => { expect(draft.subject).toBe(this.expected); }); }); }); it("works for title case", () => { waitsForPromise(() => { return DraftFactory.createDraftForMailto('mailto:asdf@asdf.com?Subject=' + this.expected).then((draft) => { expect(draft.subject).toBe(this.expected); }); }); }); it("works for uppercase", () => { waitsForPromise(() => { return DraftFactory.createDraftForMailto('mailto:asdf@asdf.com?SUBJECT=' + this.expected).then((draft) => { expect(draft.subject).toBe(this.expected); }); }); }); }); describe("should correctly instantiate drafts for a wide range of mailto URLs", () => { const links = [ 'mailto:', 'mailto://bengotow@gmail.com', 'mailto:bengotow@gmail.com', 'mailto:mg%40nylas.com', 'mailto:?subject=%1z2a', // fails uriDecode 'mailto:?subject=%52z2a', // passes uriDecode 'mailto:?subject=Martha Stewart', 'mailto:?subject=Martha Stewart&cc=cc@nylas.com', 'mailto:bengotow@gmail.com&subject=Martha Stewart&cc=cc@nylas.com', 'mailto:bengotow@gmail.com?subject=Martha%20Stewart&cc=cc@nylas.com&bcc=bcc@nylas.com', 'mailto:bengotow@gmail.com?subject=Martha%20Stewart&cc=cc@nylas.com&bcc=Ben ', 'mailto:Ben Gotow ,Shawn ?subject=Yes this is really valid', 'mailto:Ben%20Gotow%20,Shawn%20?subject=Yes%20this%20is%20really%20valid', 'mailto:Reply ?subject=Nilas%20Message%20to%20Customers', 'mailto:email@address.com?&subject=test&body=type%20your%0Amessage%20here', 'mailto:?body=type%20your%0D%0Amessage%0D%0Ahere', 'mailto:?subject=Issues%20%C2%B7%20atom/electron%20%C2%B7%20GitHub&body=https://github.com/atom/electron/issues?utf8=&q=is%253Aissue+is%253Aopen+123%0A%0A', ]; const expected = [ new Message(), new Message( {to: [new Contact({name: 'bengotow@gmail.com', email: 'bengotow@gmail.com'})]} ), new Message( {to: [new Contact({name: 'bengotow@gmail.com', email: 'bengotow@gmail.com'})]} ), new Message( {to: [new Contact({name: 'mg@nylas.com', email: 'mg@nylas.com'})]} ), new Message( {subject: '%1z2a'} ), new Message( {subject: 'Rz2a'} ), new Message( {subject: 'Martha Stewart'} ), new Message( {cc: [new Contact({name: 'cc@nylas.com', email: 'cc@nylas.com'})], subject: 'Martha Stewart'} ), new Message( {to: [new Contact({name: 'bengotow@gmail.com', email: 'bengotow@gmail.com'})], cc: [new Contact({name: 'cc@nylas.com', email: 'cc@nylas.com'})], subject: 'Martha Stewart'} ), new Message( {to: [new Contact({name: 'bengotow@gmail.com', email: 'bengotow@gmail.com'})], cc: [new Contact({name: 'cc@nylas.com', email: 'cc@nylas.com'})], bcc: [new Contact({name: 'bcc@nylas.com', email: 'bcc@nylas.com'})], subject: 'Martha Stewart'} ), new Message( {to: [new Contact({name: 'bengotow@gmail.com', email: 'bengotow@gmail.com'})], cc: [new Contact({name: 'cc@nylas.com', email: 'cc@nylas.com'})], bcc: [new Contact({name: 'Ben', email: 'bcc@nylas.com'})], subject: 'Martha Stewart'} ), new Message( {to: [new Contact({name: 'Ben Gotow', email: 'bengotow@gmail.com'}), new Contact({name: 'Shawn', email: 'shawn@nylas.com'})], subject: 'Yes this is really valid'} ), new Message( {to: [new Contact({name: 'Ben Gotow', email: 'bengotow@gmail.com'}), new Contact({name: 'Shawn', email: 'shawn@nylas.com'})], subject: 'Yes this is really valid'} ), new Message( {to: [new Contact({name: 'Reply', email: 'd+AORGpRdj0KXKUPBE1LoI0a30F10Ahj3wu3olS-aDk5_7K5Wu6WqqqG8t1HxxhlZ4KEEw3WmrSdtobgUq57SkwsYAH6tG57IrNqcQR0K6XaqLM2nGNZ22D2k@docs.google.com'})], subject: 'Nilas Message to Customers'} ), new Message( {to: [new Contact({name: 'email@address.com', email: 'email@address.com'})], subject: 'test', body: 'type your\nmessage here'} ), new Message( {to: [], body: 'type your\r\nmessage\r\nhere'} ), new Message( {to: [], subject: 'Issues · atom/electron · GitHub', body: 'https://github.com/atom/electron/issues?utf8=&q=is%3Aissue+is%3Aopen+123\n\n'}, ), ]; links.forEach((link, idx) => { it(`works for ${link}`, () => { waitsForPromise(() => { return DraftFactory.createDraftForMailto(link).then((draft) => { const expectedDraft = expected[idx]; expect(draft.subject).toEqual(expectedDraft.subject); if (expectedDraft.body) { expect(draft.body).toEqual(expectedDraft.body); } ['to', 'cc', 'bcc'].forEach((attr) => { expectedDraft[attr].forEach((expectedContact, jdx) => { const actual = draft[attr][jdx]; expect(actual instanceof Contact).toBe(true); expect(actual.email).toEqual(expectedContact.email); expect(actual.name).toEqual(expectedContact.name); }); }); }); }); }); }); }); }); });