diff --git a/Gruntfile.js b/Gruntfile.js index 2553a2f..b91ab7b 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -137,13 +137,12 @@ module.exports = function(grunt) { 'imap-client/node_modules/mimelib/node_modules/addressparser/src/addressparser.js', 'imap-client/node_modules/mimelib/node_modules/encoding/src/encoding.js', 'imap-client/node_modules/mimelib/node_modules/encoding/node_modules/iconv-lite/src/*.js', - 'imap-client/node_modules/mimelib/node_modules/encoding/node_modules/mime/src/*.js', 'imap-client/node_modules/mailparser/src/*.js', - 'imap-client/node_modules/mailparser/node_modules/mime/src/mime.js', + 'imap-client/node_modules/mime/src/mime.js', 'smtp-client/src/*.js', 'smtp-client/node_modules/mailcomposer/src/*', 'smtp-client/node_modules/nodemailer/src/*', - 'smtp-client/node_modules/nodemailer/node_modules/simplesmtp/src/*', + 'smtp-client/node_modules/nodemailer/node_modules/simplesmtp/src/*' ], dest: 'src/lib/' }, diff --git a/package.json b/package.json index 056bfc4..85beac5 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ }, "dependencies": { "crypto-lib": "https://github.com/whiteout-io/crypto-lib/tarball/master", - "imap-client": "https://github.com/whiteout-io/imap-client/tarball/master", + "imap-client": "https://github.com/whiteout-io/imap-client/tarball/dev/attachments", "smtp-client": "https://github.com/whiteout-io/smtp-client/tarball/master", "requirejs": "2.1.10" }, diff --git a/src/js/controller/mail-list.js b/src/js/controller/mail-list.js index 073f4b5..855a4ce 100644 --- a/src/js/controller/mail-list.js +++ b/src/js/controller/mail-list.js @@ -277,7 +277,14 @@ define(function(require) { this.to = [{ address: 'max.musterman@gmail.com' }]; // list of receivers - this.attachments = (attachments) ? [true] : undefined; + if (attachments) { + // body structure with three attachments + this.bodystructure = {"1": {"part": "1","type": "text/plain","parameters": {"charset": "us-ascii"},"encoding": "7bit","size": 9,"lines": 2},"2": {"part": "2","type": "application/octet-stream","parameters": {"name": "a.md"},"encoding": "7bit","size": 123,"disposition": [{"type": "attachment","filename": "a.md"}]},"3": {"part": "3","type": "application/octet-stream","parameters": {"name": "b.md"},"encoding": "7bit","size": 456,"disposition": [{"type": "attachment","filename": "b.md"}]},"4": {"part": "4","type": "application/octet-stream","parameters": {"name": "c.md"},"encoding": "7bit","size": 789,"disposition": [{"type": "attachment","filename": "c.md"}]},"type": "multipart/mixed"}; + this.attachments = [{"filename": "a.md","filesize": 123,"mimeType": "text/x-markdown","part": "2","content": null}, {"filename": "b.md","filesize": 456,"mimeType": "text/x-markdown","part": "3","content": null}, {"filename": "c.md","filesize": 789,"mimeType": "text/x-markdown","part": "4","content": null}]; + } else { + this.bodystructure = {"part": "1","type": "text/plain","parameters": {"charset": "us-ascii"},"encoding": "7bit","size": 9,"lines": 2}; + this.attachments = []; + } this.unread = unread; this.answered = answered; this.html = html; @@ -286,7 +293,7 @@ define(function(require) { this.body = 'Here are a few pointers to help you get started with Whiteout Mail.\n\n# Write encrypted message\n- You can compose a message by clicking on the compose button on the upper right (keyboard shortcut is "n" for a new message or "r" to reply).\n- When typing the recipient\'s email address, secure recipients are marked with a blue label and insecure recipients are red.\n- When sending an email to insecure recipients, the default behavior for Whiteout Mail is to invite them to the service and only send the message content in an encrypted form, once they have joined.\n\n# Advanced features\n- To verify a recipient\'s PGP key, you can hover over the blue label containing their email address and their key fingerprint will be displayed.\n- To view your own key fingerprint, open the account view in the navigation bar on the left. You can compare these with your correspondants over a second channel such as a phonecall.\n\nWe hope this helped you to get started with Whiteout Mail.\n\nYour Whiteout Networks team'; // plaintext body }; - var dummys = [new Email(true, true), new Email(true, false, true, true), new Email(false, true, true), new Email(false), new Email(false), new Email(false), new Email(false), new Email(false), new Email(false), new Email(false), new Email(false), new Email(false), new Email(false), new Email(false), new Email(false), new Email(false), new Email(false), new Email(false)]; + var dummys = [new Email(true, true), new Email(true, false, true, true), new Email(false, true, true), new Email(false, true), new Email(false), new Email(false), new Email(false), new Email(false), new Email(false), new Email(false), new Email(false), new Email(false), new Email(false), new Email(false), new Email(false), new Email(false), new Email(false), new Email(false)]; return dummys; } diff --git a/src/js/controller/read.js b/src/js/controller/read.js index 90aaac8..e14ce25 100644 --- a/src/js/controller/read.js +++ b/src/js/controller/read.js @@ -2,14 +2,17 @@ define(function(require) { 'use strict'; var appController = require('js/app-controller'), + download = require('js/util/download'), angular = require('angular'), - crypto, keychain; + emailDao, crypto, keychain; // // Controller // var ReadCtrl = function($scope) { + + emailDao = appController._emailDao; crypto = appController._crypto; keychain = appController._keychain; @@ -74,6 +77,38 @@ define(function(require) { $scope.$apply(); }); } + + $scope.download = function(attachment) { + // download file to disk if content is available + if (attachment.content) { + saveToDisk(attachment); + return; + } + + var folder = $scope.state.nav.currentFolder; + var email = $scope.state.mailList.selected; + + emailDao.getAttachment({ + path: folder.path, + uid: email.uid, + attachment: attachment + }, function(err) { + if (err) { + $scope.onError(err); + return; + } + + saveToDisk(attachment); + }); + + function saveToDisk(attachment) { + download.createDownload({ + content: attachment.content, + filename: attachment.filename, + contentType: attachment.mimeType + }, $scope.onError); + } + }; }; // diff --git a/src/js/dao/email-dao.js b/src/js/dao/email-dao.js index 15fb640..04c1def 100644 --- a/src/js/dao/email-dao.js +++ b/src/js/dao/email-dao.js @@ -805,7 +805,8 @@ define(function(require) { if (!senderPubkey) { // this should only happen if a mail from another channel is in the inbox - setBodyAndContinue('Public key for sender not found!'); + email.body = 'Public key for sender not found!'; + localCallback(null, email); return; } @@ -817,7 +818,23 @@ define(function(require) { // set encrypted flag email.encrypted = true; - setBodyAndContinue(decrypted); + + // does our message block even need to be parsed? + // this is a very primitive detection if we have a mime node or plain text + // taking this out breaks compatibility to clients < 0.5 + if (decrypted.indexOf('Content-Transfer-Encoding:') === -1 && + decrypted.indexOf('Content-Type:') === -1) { + // decrypted message is plain text and not a well-formed email + email.body = decrypted; + localCallback(null, email); + return; + } + + // parse decrypted message + self._imapParseMessageBlock({ + message: email, + block: decrypted + }, localCallback); }); }); @@ -828,11 +845,6 @@ define(function(require) { // parse email body for encrypted message block email.body = email.body.substring(start, end); } - - function setBodyAndContinue(text) { - email.body = text; - localCallback(null, email); - } } }; @@ -869,6 +881,18 @@ define(function(require) { }, callback); }; + EmailDAO.prototype.getAttachment = function(options, callback) { + if (!this._account.online) { + callback({ + errMsg: 'Client is currently offline!', + code: 42 + }); + return; + } + + this._imapClient.getAttachment(options, callback); + }; + EmailDAO.prototype.sendEncrypted = function(options, callback) { var self = this, email = options.email; @@ -1068,6 +1092,10 @@ define(function(require) { }, callback); }; + EmailDAO.prototype._imapParseMessageBlock = function(options, callback) { + this._imapClient.parseDecryptedMessageBlock(options, callback); + }; + /** * Get an email messsage including the email body from imap * @param {String} options.messageId The diff --git a/src/tpl/read.html b/src/tpl/read.html index ca2dbf9..1b0f7ff 100644 --- a/src/tpl/read.html +++ b/src/tpl/read.html @@ -21,7 +21,19 @@ -
+
+
+
+ + + +
+
+
+
+
+
+
diff --git a/test/new-unit/email-dao-test.js b/test/new-unit/email-dao-test.js index 28bfb33..4609471 100644 --- a/test/new-unit/email-dao-test.js +++ b/test/new-unit/email-dao-test.js @@ -14,7 +14,7 @@ define(function(require) { var dao, keychainStub, imapClientStub, smtpClientStub, pgpStub, devicestorageStub; var emailAddress, passphrase, asymKeySize, mockkeyId, dummyEncryptedMail, - dummyDecryptedMail, mockKeyPair, account, publicKey, verificationMail, verificationUuid, + dummyDecryptedMail, dummyLegacyDecryptedMail, mockKeyPair, account, publicKey, verificationMail, verificationUuid, corruptedVerificationMail, corruptedVerificationUuid, nonWhitelistedMail; @@ -65,6 +65,20 @@ define(function(require) { answered: false }; dummyDecryptedMail = { + uid: 1234, + from: [{ + address: 'asd@asd.de' + }], + to: [{ + address: 'qwe@qwe.de' + }], + subject: 'qweasd', + body: 'Content-Type: multipart/signed;\r\n boundary="Apple-Mail=_1D8756C0-F347-4D7A-A8DB-7869CBF14FD2";\r\n protocol="application/pgp-signature";\r\n micalg=pgp-sha512\r\n\r\n\r\n--Apple-Mail=_1D8756C0-F347-4D7A-A8DB-7869CBF14FD2\r\nContent-Type: multipart/mixed;\r\n boundary="Apple-Mail=_8ED7DC84-6AD9-4A08-8327-80B62D6BCBFA"\r\n\r\n\r\n--Apple-Mail=_8ED7DC84-6AD9-4A08-8327-80B62D6BCBFA\r\nContent-Transfer-Encoding: 7bit\r\nContent-Type: text/plain;\r\n charset=us-ascii\r\n\r\nasdasd \r\n--Apple-Mail=_8ED7DC84-6AD9-4A08-8327-80B62D6BCBFA\r\nContent-Disposition: attachment;\r\n filename=dummy.txt\r\nContent-Type: text/plain;\r\n name="dummy.txt"\r\nContent-Transfer-Encoding: 7bit\r\n\r\noaudbcoaurbvosuabvlasdjbfalwubjvawvb\r\n--Apple-Mail=_8ED7DC84-6AD9-4A08-8327-80B62D6BCBFA--\r\n\r\n--Apple-Mail=_1D8756C0-F347-4D7A-A8DB-7869CBF14FD2\r\nContent-Transfer-Encoding: 7bit\r\nContent-Disposition: attachment;\r\n filename=signature.asc\r\nContent-Type: application/pgp-signature;\r\n name=signature.asc\r\nContent-Description: Message signed with OpenPGP using GPGMail\r\n\r\n-----BEGIN PGP SIGNATURE-----\r\nComment: GPGTools - https://gpgtools.org\r\n\r\niQEcBAEBCgAGBQJS2kO1AAoJEDzmUwH7XO/cP+YH/2PSBxX1ZZd83Uf9qBGDY807\r\niHOdgPFXm64YjSnohO7XsPcnmihqP1ipS2aaCXFC3/Vgb9nc4isQFS+i1VdPwfuR\r\n1Pd2l3dC4/nD4xO9h/W6JW7Yd24NS5TJD5cA7LYwQ8LF+rOzByMatiTMmecAUCe8\r\nEEalEjuogojk4IacA8dg/bfLqQu9E+0GYUJBcI97dx/0jZ0qMOxbWOQLsJ3DnUnV\r\nOad7pAIbHEO6T0EBsH7TyTj4RRHkP6SKE0mm6ZYUC7KCk2Z3MtkASTxUrnqW5qZ5\r\noaXUO9GEc8KZcmbCdhZY2Y5h+dmucaO0jpbeSKkvtYyD4KZrSvt7NTb/0dSLh4Y=\r\n=G8km\r\n-----END PGP SIGNATURE-----\r\n\r\n--Apple-Mail=_1D8756C0-F347-4D7A-A8DB-7869CBF14FD2--\r\n', + unread: false, + answered: false, + receiverKeys: ['-----BEGIN PGP PUBLIC KEY-----\nasd\n-----END PGP PUBLIC KEY-----'] + }; + dummyLegacyDecryptedMail = { uid: 1234, from: [{ address: 'asd@asd.de' @@ -471,6 +485,19 @@ define(function(require) { }); }); + describe('_imapParseMessageBlock', function() { + it('should parse a message', function(done) { + imapClientStub.parseDecryptedMessageBlock.yields(null, {}); + + dao._imapParseMessageBlock(function(err, msg) { + expect(err).to.not.exist; + expect(msg).to.exist; + done(); + }); + + }); + }); + describe('_imapLogin', function() { it('should fail when disconnected', function(done) { dao.onDisconnect(null, function(err) { @@ -823,7 +850,7 @@ define(function(require) { describe('sync', function() { it('should work initially', function(done) { - var folder, localListStub, invocations, imapSearchStub; + var folder, localListStub, invocations, imapSearchStub, imapParseStub; invocations = 0; folder = 'FOLDAAAA'; @@ -852,6 +879,13 @@ define(function(require) { answered: true }).yields(null, []); + imapParseStub = sinon.stub(dao, '_imapParseMessageBlock'); + imapParseStub.withArgs({ + message: dummyEncryptedMail, + block: dummyDecryptedMail.body + }).yields(null, dummyDecryptedMail); + + dao.sync({ folder: folder }, function(err) { @@ -870,6 +904,7 @@ define(function(require) { expect(pgpStub.decrypt.calledOnce).to.be.true; expect(imapSearchStub.calledThrice).to.be.true; expect(dao._account.folders[0].count).to.equal(1); + expect(imapParseStub.calledOnce).to.be.true; done(); }); @@ -903,7 +938,7 @@ define(function(require) { }); it('should initially sync downstream when storage is empty', function(done) { - var folder, localListStub, localStoreStub, invocations, imapSearchStub, imapGetStub; + var folder, localListStub, localStoreStub, invocations, imapSearchStub, imapGetStub, imapParseStub; invocations = 0; folder = 'FOLDAAAA'; @@ -912,8 +947,8 @@ define(function(require) { path: folder }]; - dummyEncryptedMail.unread = true; - dummyEncryptedMail.answered = true; + dummyDecryptedMail.unread = true; + dummyDecryptedMail.answered = true; localListStub = sinon.stub(dao, '_localListMessages').withArgs({ folder: folder @@ -938,6 +973,9 @@ define(function(require) { answered: true }).yields(null, [dummyEncryptedMail.uid]); + imapParseStub = sinon.stub(dao, '_imapParseMessageBlock'); + imapParseStub.yields(null, dummyDecryptedMail); + localStoreStub = sinon.stub(dao, '_localStoreMessages').yields(); dao.sync({ @@ -960,6 +998,7 @@ define(function(require) { expect(keychainStub.getReceiverPublicKey.calledOnce).to.be.true; expect(pgpStub.decrypt.calledOnce).to.be.true; expect(dao._account.folders[0].count).to.equal(1); + expect(imapParseStub.calledOnce).to.be.true; done(); }); @@ -1162,7 +1201,7 @@ define(function(require) { }); }); - it('should error whilte removing messages from local', function(done) { + it('should error while removing messages from local', function(done) { var invocations, folder, localListStub, imapSearchStub, localDeleteStub, imapDeleteStub; invocations = 0; @@ -1316,7 +1355,7 @@ define(function(require) { }); }); - it('should fetch messages downstream from the remote', function(done) { + it('should fetch legacy messages downstream from the remote', function(done) { var invocations, folder, localListStub, imapSearchStub, imapGetStub, localStoreStub; invocations = 0; @@ -1348,10 +1387,75 @@ define(function(require) { uid: dummyEncryptedMail.uid }).yields(null, dummyEncryptedMail); + localStoreStub = sinon.stub(dao, '_localStoreMessages').yields(); + keychainStub.getReceiverPublicKey.withArgs(dummyEncryptedMail.from[0].address).yields(null, mockKeyPair); + pgpStub.decrypt.withArgs(dummyEncryptedMail.body, mockKeyPair.publicKey).yields(null, dummyLegacyDecryptedMail.body); + + + dao.sync({ + folder: folder + }, function(err) { + expect(err).to.not.exist; + + if (invocations === 0) { + expect(dao._account.busy).to.be.true; + invocations++; + return; + } + + expect(dao._account.busy).to.be.false; + expect(dao._account.folders[0].messages).to.not.be.empty; + expect(localListStub.calledOnce).to.be.true; + expect(imapSearchStub.calledThrice).to.be.true; + expect(imapGetStub.calledOnce).to.be.true; + expect(localStoreStub.calledOnce).to.be.true; + expect(keychainStub.getReceiverPublicKey.calledOnce).to.be.true; + expect(pgpStub.decrypt.calledOnce).to.be.true; + done(); + }); + }); + + it('should fetch valid pgp messages downstream from the remote', function(done) { + var invocations, folder, localListStub, imapSearchStub, imapGetStub, localStoreStub, imapParseStub; + + invocations = 0; + folder = 'FOLDAAAA'; + dao._account.folders = [{ + type: 'Folder', + path: folder, + messages: [] + }]; + + localListStub = sinon.stub(dao, '_localListMessages').withArgs({ + folder: folder + }).yields(null, []); + imapSearchStub = sinon.stub(dao, '_imapSearch'); + imapSearchStub.withArgs({ + folder: folder + }).yields(null, [dummyEncryptedMail.uid]); + imapSearchStub.withArgs({ + folder: folder, + unread: true + }).yields(null, []); + imapSearchStub.withArgs({ + folder: folder, + answered: true + }).yields(null, []); + + imapGetStub = sinon.stub(dao, '_imapGetMessage').withArgs({ + folder: folder, + uid: dummyEncryptedMail.uid + }).yields(null, dummyEncryptedMail); + localStoreStub = sinon.stub(dao, '_localStoreMessages').yields(); keychainStub.getReceiverPublicKey.withArgs(dummyEncryptedMail.from[0].address).yields(null, mockKeyPair); pgpStub.decrypt.withArgs(dummyEncryptedMail.body, mockKeyPair.publicKey).yields(null, dummyDecryptedMail.body); + imapParseStub = sinon.stub(dao, '_imapParseMessageBlock'); + imapParseStub.withArgs({ + message: dummyEncryptedMail, + block: dummyDecryptedMail.body + }).yields(null, dummyDecryptedMail); dao.sync({ folder: folder @@ -1372,6 +1476,7 @@ define(function(require) { expect(localStoreStub.calledOnce).to.be.true; expect(keychainStub.getReceiverPublicKey.calledOnce).to.be.true; expect(pgpStub.decrypt.calledOnce).to.be.true; + expect(imapParseStub.calledOnce).to.be.true; done(); }); });