From 9bc2bc791292d24fd31a42d96cf30eeb2ccc09c7 Mon Sep 17 00:00:00 2001 From: Tankred Hase Date: Wed, 21 Jan 2015 12:04:08 +0100 Subject: [PATCH] Show invite dialog in writer when recipient has no public key --- src/js/app-config.js | 1 + src/js/controller/app/read.js | 18 +-- src/js/controller/app/write.js | 59 +++++++++- src/js/crypto/pgp.js | 2 +- src/js/email/outbox.js | 6 + src/js/service/invitation.js | 28 ++++- src/sass/blocks/views/_write.scss | 27 +++++ src/tpl/write.html | 16 ++- test/unit/controller/app/write-ctrl-test.js | 120 +++++++++++++++++++- test/unit/email/outbox-bo-test.js | 18 +++ 10 files changed, 270 insertions(+), 25 deletions(-) diff --git a/src/js/app-config.js b/src/js/app-config.js index 392639b..64ec102 100644 --- a/src/js/app-config.js +++ b/src/js/app-config.js @@ -12,6 +12,7 @@ module.exports = appCfg; * Global app configurations */ appCfg.config = { + pgpComment: 'Whiteout Mail - https://whiteout.io', keyServerUrl: 'https://keys.whiteout.io', hkpUrl: 'http://keyserver.ubuntu.com', privkeyServerUrl: 'https://keychain.whiteout.io', diff --git a/src/js/controller/app/read.js b/src/js/controller/app/read.js index bae4f48..2f5ecad 100644 --- a/src/js/controller/app/read.js +++ b/src/js/controller/app/read.js @@ -6,8 +6,6 @@ var ReadCtrl = function($scope, $location, $q, email, invitation, outbox, pgp, keychain, appConfig, download, auth, dialog, status) { - var str = appConfig.string; - // // scope state // @@ -158,18 +156,10 @@ var ReadCtrl = function($scope, $location, $q, email, invitation, outbox, pgp, k }); }).then(function() { - var invitationMail = { - from: [{ - address: sender - }], - to: [{ - address: recipient - }], - cc: [], - bcc: [], - subject: str.invitationSubject, - body: str.invitationMessage - }; + var invitationMail = invitation.createMail({ + sender: sender, + recipient: recipient + }); // send invitation mail return outbox.put(invitationMail); diff --git a/src/js/controller/app/write.js b/src/js/controller/app/write.js index 6ab5b86..caa4835 100644 --- a/src/js/controller/app/write.js +++ b/src/js/controller/app/write.js @@ -6,7 +6,7 @@ var util = require('crypto-lib').util; // Controller // -var WriteCtrl = function($scope, $window, $filter, $q, appConfig, auth, keychain, pgp, email, outbox, dialog, axe, status) { +var WriteCtrl = function($scope, $window, $filter, $q, appConfig, auth, keychain, pgp, email, outbox, dialog, axe, status, invitation) { var str = appConfig.string; var cfg = appConfig.config; @@ -52,6 +52,8 @@ var WriteCtrl = function($scope, $window, $filter, $q, appConfig, auth, keychain $scope.body = ''; $scope.attachments = []; $scope.addressBookCache = undefined; + $scope.showInvite = undefined; + $scope.invited = []; } function reportBug() { @@ -248,6 +250,9 @@ var WriteCtrl = function($scope, $window, $filter, $q, appConfig, auth, keychain recipient.key = key; recipient.secure = true; } + } else { + // show invite dialog if no key found + $scope.showInvite = true; } $scope.checkSendStatus(); @@ -286,6 +291,7 @@ var WriteCtrl = function($scope, $window, $filter, $q, appConfig, auth, keychain // only allow sending if receviers exist if (numReceivers < 1) { + $scope.showInvite = false; return; } @@ -299,6 +305,7 @@ var WriteCtrl = function($scope, $window, $filter, $q, appConfig, auth, keychain $scope.okToSend = true; $scope.sendBtnText = str.sendBtnSecure; $scope.sendBtnSecure = true; + $scope.showInvite = false; } else { // send plaintext $scope.okToSend = true; @@ -315,6 +322,56 @@ var WriteCtrl = function($scope, $window, $filter, $q, appConfig, auth, keychain $scope.attachments.splice($scope.attachments.indexOf(attachment), 1); }; + /** + * Invite all users without a public key + */ + $scope.invite = function() { + var sender = auth.emailAddress, + sendJobs = [], + invitees = []; + + $scope.showInvite = false; + + // get recipients with no keys + $scope.to.forEach(check); + $scope.cc.forEach(check); + $scope.bcc.forEach(check); + + function check(recipient) { + if (util.validateEmailAddress(recipient.address) && !recipient.secure && $scope.invited.indexOf(recipient.address) === -1) { + invitees.push(recipient.address); + } + } + + return $q(function(resolve) { + resolve(); + + }).then(function() { + invitees.forEach(function(recipientAddress) { + var invitationMail = invitation.createMail({ + sender: sender, + recipient: recipientAddress + }); + // send invitation mail + var promise = outbox.put(invitationMail).then(function() { + return invitation.invite({ + recipient: recipientAddress, + sender: sender + }); + }); + sendJobs.push(promise); + // remember already invited users to prevent spamming + $scope.invited.push(recipientAddress); + }); + + return Promise.all(sendJobs); + + }).catch(function(err) { + $scope.showInvite = true; + return dialog.error(err); + }); + }; + // // Editing email body // diff --git a/src/js/crypto/pgp.js b/src/js/crypto/pgp.js index a048fd6..807eb65 100644 --- a/src/js/crypto/pgp.js +++ b/src/js/crypto/pgp.js @@ -11,7 +11,7 @@ var util = openpgp.util, * High level crypto api that handles all calls to OpenPGP.js */ function PGP() { - openpgp.config.commentstring = 'Whiteout Mail - https://whiteout.io'; + openpgp.config.commentstring = config.pgpComment; openpgp.config.prefer_hash_algorithm = openpgp.enums.hash.sha256; openpgp.initWorker(config.workerPath + '/openpgp.worker.min.js'); } diff --git a/src/js/email/outbox.js b/src/js/email/outbox.js index e0479c8..f84d542 100644 --- a/src/js/email/outbox.js +++ b/src/js/email/outbox.js @@ -62,6 +62,12 @@ Outbox.prototype.put = function(mail) { var self = this, allReaders = mail.from.concat(mail.to.concat(mail.cc.concat(mail.bcc))); // all the users that should be able to read the mail + if (mail.to.concat(mail.cc.concat(mail.bcc)).length === 0) { + return new Promise(function() { + throw new Error('Message has no recipients!'); + }); + } + mail.publicKeysArmored = []; // gather the public keys mail.uid = mail.id = util.UUID(); // the mail needs a random id & uid for storage in the database diff --git a/src/js/service/invitation.js b/src/js/service/invitation.js index 377378a..317a768 100644 --- a/src/js/service/invitation.js +++ b/src/js/service/invitation.js @@ -8,13 +8,33 @@ module.exports = Invitation; * The Invitation is a high level Data Access Object that access the invitation service REST endpoint. * @param {Object} restDao The REST Data Access Object abstraction */ -function Invitation(invitationRestDao) { +function Invitation(invitationRestDao, appConfig) { this._restDao = invitationRestDao; + this._appConfig = appConfig; } -// -// API -// +/** + * Create the invitation mail object + * @param {String} options.sender The sender's email address + * @param {String} options.recipient The recipient's email address + * @return {Object} The mail object + */ +Invitation.prototype.createMail = function(options) { + var str = this._appConfig.string; + + return { + from: [{ + address: options.sender + }], + to: [{ + address: options.recipient + }], + cc: [], + bcc: [], + subject: str.invitationSubject, + body: str.invitationMessage + }; +}; /** * Notes an invite for the recipient by the sender in the invitation web service diff --git a/src/sass/blocks/views/_write.scss b/src/sass/blocks/views/_write.scss index d7e2257..0748436 100644 --- a/src/sass/blocks/views/_write.scss +++ b/src/sass/blocks/views/_write.scss @@ -21,6 +21,33 @@ margin-top: 0.5em; } } + &__invite { + position: relative; + margin-top: 1.3em; + border: 1px solid $color-red-light; + + p { + color: $color-red-light; + margin: 0.7em 1em; + + svg { + width: 1em; + height: 1em; + fill: $color-red-light; + + // for better valignment + position: relative; + top: 0.15em; + margin-right: 0.3em; + } + } + + .btn { + position: absolute; + top: 5px; + right: 5px; + } + } &__subject { position: relative; margin-top: 1.3em; diff --git a/src/tpl/write.html b/src/tpl/write.html index 1ec6bc8..c1768d0 100644 --- a/src/tpl/write.html +++ b/src/tpl/write.html @@ -41,6 +41,18 @@ +
+

+ + Key not found! + Invite user to encrypt. +

+ +
+
@@ -61,10 +73,10 @@ +
- - \ No newline at end of file + \ No newline at end of file diff --git a/test/unit/controller/app/write-ctrl-test.js b/test/unit/controller/app/write-ctrl-test.js index 23eb6bd..e27815e 100644 --- a/test/unit/controller/app/write-ctrl-test.js +++ b/test/unit/controller/app/write-ctrl-test.js @@ -7,11 +7,12 @@ var WriteCtrl = require('../../../../src/js/controller/app/write'), Auth = require('../../../../src/js/service/auth'), PGP = require('../../../../src/js/crypto/pgp'), Status = require('../../../../src/js/util/status'), - Dialog = require('../../../../src/js/util/dialog'); + Dialog = require('../../../../src/js/util/dialog'), + Invitation = require('../../../../src/js/service/invitation'); describe('Write controller unit test', function() { var ctrl, scope, - authMock, pgpMock, dialogMock, emailMock, keychainMock, outboxMock, statusMock, + authMock, pgpMock, dialogMock, emailMock, keychainMock, outboxMock, statusMock, invitationMock, emailAddress, realname; beforeEach(function() { @@ -23,6 +24,7 @@ describe('Write controller unit test', function() { emailMock = sinon.createStubInstance(Email); keychainMock = sinon.createStubInstance(Keychain); statusMock = sinon.createStubInstance(Status); + invitationMock = sinon.createStubInstance(Invitation); emailAddress = 'fred@foo.com'; realname = 'Fred Foo'; @@ -43,7 +45,8 @@ describe('Write controller unit test', function() { email: emailMock, outbox: outboxMock, dialog: dialogMock, - status: statusMock + status: statusMock, + invitation: invitationMock }); }); }); @@ -205,6 +208,25 @@ describe('Write controller unit test', function() { }); }); + it('should work for no key in keychain', function(done) { + var recipient = { + address: 'asds@example.com' + }; + + keychainMock.refreshKeyForUserId.withArgs({ + userId: recipient.address + }).returns(resolves()); + + scope.verify(recipient).then(function() { + expect(recipient.key).to.be.undefined; + expect(recipient.secure).to.be.false; + expect(scope.showInvite).to.be.true; + expect(scope.checkSendStatus.callCount).to.equal(2); + expect(keychainMock.refreshKeyForUserId.calledOnce).to.be.true; + done(); + }); + }); + it('should work for main userId', function(done) { var recipient = { address: 'asdf@example.com' @@ -226,6 +248,7 @@ describe('Write controller unit test', function() { userId: 'asdf@example.com' }); expect(recipient.secure).to.be.true; + expect(scope.showInvite).to.be.undefined; expect(scope.checkSendStatus.callCount).to.equal(2); expect(keychainMock.refreshKeyForUserId.calledOnce).to.be.true; done(); @@ -252,6 +275,7 @@ describe('Write controller unit test', function() { scope.verify(recipient).then(function() { expect(recipient.key).to.deep.equal(key); expect(recipient.secure).to.be.true; + expect(scope.showInvite).to.be.undefined; expect(scope.checkSendStatus.callCount).to.equal(2); expect(keychainMock.refreshKeyForUserId.calledOnce).to.be.true; done(); @@ -272,6 +296,7 @@ describe('Write controller unit test', function() { expect(scope.okToSend).to.be.false; expect(scope.sendBtnText).to.be.undefined; expect(scope.sendBtnSecure).to.be.undefined; + expect(scope.showInvite).to.be.false; }); it('should be able to send plaintext', function() { @@ -312,6 +337,95 @@ describe('Write controller unit test', function() { expect(scope.okToSend).to.be.true; expect(scope.sendBtnText).to.equal('Send securely'); expect(scope.sendBtnSecure).to.be.true; + expect(scope.showInvite).to.be.false; + }); + }); + + describe('invite', function() { + beforeEach(function() { + scope.state.writer.write(); + }); + + afterEach(function() {}); + + it('should not invite anyone', function(done) { + scope.invite().then(function() { + expect(scope.showInvite).to.be.false; + expect(outboxMock.put.called).to.be.false; + expect(invitationMock.invite.called).to.be.false; + done(); + }); + }); + + it('should work', function(done) { + scope.to = [{ + address: 'asdf@asdf.de' + }, { + address: 'qwer@asdf.de' + }]; + + outboxMock.put.returns(resolves()); + invitationMock.invite.returns(resolves()); + + scope.invite().then(function() { + expect(scope.showInvite).to.be.false; + expect(outboxMock.put.callCount).to.equal(2); + expect(invitationMock.invite.callCount).to.equal(2); + done(); + }); + }); + + it('should work for one already invited', function(done) { + scope.to = [{ + address: 'asdf@asdf.de' + }, { + address: 'qwer@asdf.de' + }]; + scope.invited.push('asdf@asdf.de'); + + outboxMock.put.returns(resolves()); + invitationMock.invite.returns(resolves()); + + scope.invite().then(function() { + expect(scope.showInvite).to.be.false; + expect(outboxMock.put.callCount).to.equal(1); + expect(invitationMock.invite.callCount).to.equal(1); + done(); + }); + }); + + it('should fail due to error in outbox.put', function(done) { + scope.to = [{ + address: 'asdf@asdf.de' + }]; + + outboxMock.put.returns(rejects(new Error('Peng'))); + invitationMock.invite.returns(resolves()); + + scope.invite().then(function() { + expect(dialogMock.error.calledOnce).to.be.true; + expect(scope.showInvite).to.be.true; + expect(outboxMock.put.callCount).to.equal(1); + expect(invitationMock.invite.callCount).to.equal(0); + done(); + }); + }); + + it('should fail due to error in invitation.invite', function(done) { + scope.to = [{ + address: 'asdf@asdf.de' + }]; + + outboxMock.put.returns(resolves()); + invitationMock.invite.returns(rejects(new Error('Peng'))); + + scope.invite().then(function() { + expect(dialogMock.error.calledOnce).to.be.true; + expect(scope.showInvite).to.be.true; + expect(outboxMock.put.callCount).to.equal(1); + expect(invitationMock.invite.callCount).to.equal(1); + done(); + }); }); }); diff --git a/test/unit/email/outbox-bo-test.js b/test/unit/email/outbox-bo-test.js index df86b6b..698ddd3 100644 --- a/test/unit/email/outbox-bo-test.js +++ b/test/unit/email/outbox-bo-test.js @@ -49,6 +49,24 @@ describe('Outbox unit test', function() { outbox._processOutbox.restore(); }); + it('should throw error for message without recipients', function(done) { + var mail = { + from: [{ + name: 'member', + address: 'member@whiteout.io' + }], + to: [], + cc: [], + bcc: [] + }; + + outbox.put(mail).catch(function(err) { + expect(err).to.exist; + expect(keychainStub.getReceiverPublicKey.called).to.be.false; + done(); + }); + }); + it('should not encrypt and store a mail', function(done) { var mail, senderKey, receiverKey;