diff options
author | Christoph Wurst <christoph@winzerhof-wurst.at> | 2020-05-04 18:28:12 +0300 |
---|---|---|
committer | Christoph Wurst <christoph@winzerhof-wurst.at> | 2020-05-12 15:54:39 +0300 |
commit | 7d035b022c741a292dd1220154f0f74af5cd3548 (patch) | |
tree | 756ccf738e0a90c849e0f82dba7c63f3d0a3f0e7 | |
parent | 6f757189f3f345af082fc530095c058a68fa413a (diff) |
Fix mailvelope integration by switching to the API version
Signed-off-by: Christoph Wurst <christoph@winzerhof-wurst.at>
-rwxr-xr-x | css/mail.scss | 4 | ||||
-rw-r--r-- | src/components/AppSettingsMenu.vue | 6 | ||||
-rw-r--r-- | src/components/Composer.vue | 110 | ||||
-rw-r--r-- | src/components/MailvelopeEditor.vue | 82 | ||||
-rw-r--r-- | src/components/Message.vue | 11 | ||||
-rw-r--r-- | src/components/MessageEncryptedBody.vue | 42 | ||||
-rw-r--r-- | src/components/NewMessageDetail.vue | 3 | ||||
-rw-r--r-- | src/crypto/mailvelope.js | 48 | ||||
-rw-r--r-- | src/crypto/pgp.js | 29 | ||||
-rw-r--r-- | src/tests/unit/crypto/mailvelope.spec.js | 49 | ||||
-rw-r--r-- | src/tests/unit/crypto/pgp.spec.js | 49 |
11 files changed, 411 insertions, 22 deletions
diff --git a/css/mail.scss b/css/mail.scss index 918f1d03a..54d6668ee 100755 --- a/css/mail.scss +++ b/css/mail.scss @@ -316,10 +316,6 @@ .mailaccount-list .actions { opacity: .5; } -.app-settings-hint { - margin-top: 15px; - color: #555; -} /* icons */ .icon-inbox { diff --git a/src/components/AppSettingsMenu.vue b/src/components/AppSettingsMenu.vue index 5a3c8dfdf..39490f143 100644 --- a/src/components/AppSettingsMenu.vue +++ b/src/components/AppSettingsMenu.vue @@ -48,12 +48,6 @@ {{ t('mail', 'Keyboard shortcuts') }} </router-link> </p> - - <p class="app-settings-hint app-settings-link"> - <a href="https://www.mailvelope.com/" target="_blank">{{ - t('mail', 'Looking for a way to encrypt your emails? Install the Mailvelope browser extension!') - }}</a> - </p> </div> </template> diff --git a/src/components/Composer.vue b/src/components/Composer.vue index 490f62bed..d9bf0b372 100644 --- a/src/components/Composer.vue +++ b/src/components/Composer.vue @@ -103,13 +103,20 @@ @keyup="onInputChanged" /> </div> - <div v-if="noReply" class="warning noreply-box"> + <div v-if="noReply" class="warning noreply-warning"> {{ t('mail', 'This message came from a noreply address so your reply will probably not be read.') }} </div> + <div v-if="mailvelope.keysMissing.length" class="warning noreply-warning"> + {{ + t('mail', 'The following recipients do not have a PGP key: {recipients}.', { + recipients: mailvelope.keysMissing.join(', '), + }) + }} + </div> <div class="composer-fields"> <!--@keypress="onBodyKeyPress"--> <TextEditor - v-if="editorPlainText" + v-if="!encrypt && editorPlainText" key="editor-plain" v-model="bodyVal" name="body" @@ -120,7 +127,7 @@ @input="onInputChanged" ></TextEditor> <TextEditor - v-else + v-else-if="!encrypt && !editorPlainText" key="editor-rich" v-model="bodyVal" :html="true" @@ -131,6 +138,14 @@ :bus="bus" @input="onInputChanged" ></TextEditor> + <MailvelopeEditor + v-else + ref="mailvelopeEditor" + v-model="bodyVal" + :recipients="allRecipients" + :quoted-text="body" + :is-reply-or-forward="isReply || isForward" + /> </div> <div class="composer-actions"> <ComposerAttachments v-model="attachments" :bus="bus" @upload="onAttachmentsUploading" /> @@ -146,16 +161,26 @@ <ActionButton icon="icon-folder" @click="onAddCloudAttachment">{{ t('mail', 'Add attachment from Files') }}</ActionButton> - <ActionButton icon="icon-folder" @click="onAddCloudAttachmentLink">{{ + <ActionButton :disabled="encrypt" icon="icon-folder" @click="onAddCloudAttachmentLink">{{ t('mail', 'Add attachment link from Files') }}</ActionButton> <ActionCheckbox - :checked="!editorPlainText" - :text="t('mail', 'Enable formatting')" + :checked="!encrypt && !editorPlainText" + :disabled="encrypt" @check="editorPlainText = false" @uncheck="editorPlainText = true" >{{ t('mail', 'Enable formatting') }}</ActionCheckbox > + <ActionCheckbox + v-if="mailvelope.available" + :checked="encrypt" + @check="encrypt = true" + @uncheck="encrypt = false" + >{{ t('mail', 'Encrypt message with Mailvelope') }}</ActionCheckbox + > + <ActionLink v-else href="https://www.mailvelope.com/" target="_blank" icon="icon-password">{{ + t('mail', 'Looking for a way to encrypt your emails? Install the Mailvelope browser extension!') + }}</ActionLink> </Actions> <div> <input @@ -193,17 +218,21 @@ import debouncePromise from 'debounce-promise' import Actions from '@nextcloud/vue/dist/Components/Actions' import ActionButton from '@nextcloud/vue/dist/Components/ActionButton' import ActionCheckbox from '@nextcloud/vue/dist/Components/ActionCheckbox' +import ActionLink from '@nextcloud/vue/dist/Components/ActionLink' import Multiselect from '@nextcloud/vue/dist/Components/Multiselect' import {translate as t} from '@nextcloud/l10n' import Vue from 'vue' import ComposerAttachments from './ComposerAttachments' import {findRecipient} from '../service/AutocompleteService' -import {detect, html, toHtml, toPlain} from '../util/text' +import {detect, html, plain, toHtml, toPlain} from '../util/text' import Loading from './Loading' import logger from '../logger' import TextEditor from './TextEditor' import {buildReplyBody} from '../ReplyBuilder' +import MailvelopeEditor from './MailvelopeEditor' +import {getMailvelope} from '../crypto/mailvelope' +import {isPgpgMessage} from '../crypto/pgp' const debouncedSearch = debouncePromise(findRecipient, 500) @@ -220,9 +249,11 @@ const STATES = Object.seal({ export default { name: 'Composer', components: { + MailvelopeEditor, Actions, ActionButton, ActionCheckbox, + ActionLink, ComposerAttachments, Loading, Multiselect, @@ -282,7 +313,6 @@ export default { bodyVal: toHtml(this.body).value, attachments: [], noReply: this.to.some((to) => to.email.startsWith('noreply@') || to.email.startsWith('no-reply@')), - submitButtonTitle: t('mail', 'Send'), draftsPromise: Promise.resolve(), attachmentsPromise: Promise.resolve(), savingDraft: undefined, @@ -294,21 +324,37 @@ export default { selectCc: this.cc, selectBcc: this.bcc, bus: new Vue(), + encrypt: false, + mailvelope: { + available: false, + keyRing: undefined, + keysMissing: [], + }, } }, computed: { aliases() { return this.$store.getters.accounts.filter((a) => !a.isUnified) }, + allRecipients() { + return this.selectTo.concat(this.selectCc).concat(this.selectBcc) + }, selectableRecipients() { return this.newRecipients .concat(this.autocompleteRecipients) .map((recipient) => ({...recipient, label: recipient.label || recipient.email})) }, + isForward() { + return this.forwardFrom !== undefined + }, isReply() { return this.replyTo !== undefined }, canSend() { + if (this.encrypt && this.mailvelope.keysMissing.length) { + return false + } + return this.selectTo.length > 0 || this.selectCc.length > 0 || this.selectBcc.length > 0 }, editorPlainText() { @@ -317,15 +363,27 @@ export default { } return false }, + submitButtonTitle() { + if (!this.mailvelope.available) { + return t('mail', 'Send') + } + + return this.encrypt ? t('mail', 'Encrypt and send') : t('mail', 'Send unencrypted') + }, }, watch: { '$route.params.messageUid'(newID) { this.reset() }, + allRecipients() { + this.checkRecipientsKeys() + }, }, - beforeMount() { + async beforeMount() { this.setAlias() this.initBody() + + await this.onMailvelopeLoaded(await getMailvelope()) }, mounted() { this.$refs.toLabel.$el.focus() @@ -343,6 +401,8 @@ export default { }, beforeDestroy() { this.$root.$off('newMessage') + + window.removeEventListener('mailvelope', this.onMailvelopeLoaded) }, methods: { setAlias() { @@ -352,6 +412,16 @@ export default { this.selectedAlias = this.aliases[0] } }, + async checkRecipientsKeys() { + if (!this.encrypt || !this.mailvelope.available) { + return + } + + const recipients = this.allRecipients.map((r) => r.email) + const keysValid = await this.mailvelope.keyRing.validKeyForAddress(recipients) + logger.debug('recipients keys validated', {recipients, keysValid}) + this.mailvelope.keysMissing = recipients.filter((r) => keysValid[r] === false) + }, initBody() { if (this.replyTo) { this.bodyVal = this.bodyWithSignature( @@ -398,7 +468,7 @@ export default { bcc: this.selectBcc.map(this.recipientToRfc822).join(', '), draftUID: uid, subject: this.subjectVal, - body: html(this.bodyVal), + body: this.encrypt ? plain(this.bodyVal) : html(this.bodyVal), attachments: this.attachments, folderId: this.replyTo ? this.replyTo.folderId : undefined, messageId: this.replyTo ? this.replyTo.id : undefined, @@ -460,6 +530,17 @@ export default { .catch((error) => logger.error('could not upload attachments', {error})) .then(() => logger.debug('attachments uploaded')) }, + async onMailvelopeLoaded(mailvelope) { + this.encrypt = isPgpgMessage(this.body) + this.mailvelope.available = true + logger.info('Mailvelope loaded', { + encrypt: this.encrypt, + isPgpgMessage: isPgpgMessage(this.body), + keyRing: this.mailvelope.keyRing, + }) + this.mailvelope.keyRing = await mailvelope.getKeyring() + await this.checkRecipientsKeys() + }, onNewToAddr(addr) { this.onNewAddr(addr, this.selectTo) }, @@ -477,7 +558,12 @@ export default { this.newRecipients.push(res) list.push(res) }, - onSend() { + async onSend() { + if (this.encrypt) { + logger.debug('get encrypted message from mailvelope') + await this.$refs.mailvelopeEditor.pull() + } + this.state = STATES.UPLOADING return this.attachmentsPromise @@ -598,7 +684,7 @@ export default { padding: 24px 12px; } -.noreply-box { +.warning-box { padding: 5px 12px; border-radius: 0; } diff --git a/src/components/MailvelopeEditor.vue b/src/components/MailvelopeEditor.vue new file mode 100644 index 000000000..13e8bcf29 --- /dev/null +++ b/src/components/MailvelopeEditor.vue @@ -0,0 +1,82 @@ +<!-- + - @copyright 2020 Christoph Wurst <christoph@winzerhof-wurst.at> + - + - @author 2020 Christoph Wurst <christoph@winzerhof-wurst.at> + - + - @license GNU AGPL version 3 or any later version + - + - This program is free software: you can redistribute it and/or modify + - it under the terms of the GNU Affero General Public License as + - published by the Free Software Foundation, either version 3 of the + - License, or (at your option) any later version. + - + - This program is distributed in the hope that it will be useful, + - but WITHOUT ANY WARRANTY; without even the implied warranty of + - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + - GNU Affero General Public License for more details. + - + - You should have received a copy of the GNU Affero General Public License + - along with this program. If not, see <http://www.gnu.org/licenses/>. + --> + +<template> + <div id="mailvelope-composer"></div> +</template> + +<script> +import logger from '../logger' +import {isPgpgMessage} from '../crypto/pgp' + +export default { + name: 'MailvelopeEditor', + props: { + value: { + type: String, + required: true, + }, + recipients: { + type: Array, + required: true, + }, + quotedText: { + type: Object, + required: false, + default: () => undefined, + }, + isReplyOrForward: { + type: Boolean, + default: false, + }, + }, + data() { + return { + editor: undefined, + } + }, + async mounted() { + const isEncrypted = this.quotedText ? isPgpgMessage(this.quotedText) : false + const quotedMail = this.isReplyOrForward ? this.quotedText?.value : undefined + + this.editor = await window.mailvelope.createEditorContainer('#mailvelope-composer', undefined, { + quotedMail: isEncrypted ? quotedMail : undefined, + }) + }, + methods: { + async pull() { + const recipients = this.recipients.map((r) => r.email) + logger.info('encrypting message', {recipients}) + const armored = await this.editor.encrypt(recipients) + logger.info('message encryted', {armored}) + + this.$emit('input', armored) + }, + }, +} +</script> + +<style scoped> +#mailvelope-composer { + width: 100%; + height: 450px; +} +</style> diff --git a/src/components/Message.vue b/src/components/Message.vue index 614a9daff..e6305de75 100644 --- a/src/components/Message.vue +++ b/src/components/Message.vue @@ -66,6 +66,7 @@ <Itinerary :entries="message.itineraries" :message-id="message.messageId" /> </div> <MessageHTMLBody v-if="message.hasHtmlBody" :url="htmlUrl" /> + <MessageEncryptedBody v-else-if="isEncrypted" :body="message.body" :from="from" /> <MessagePlainTextBody v-else :body="message.body" :signature="message.signature" /> <Popover v-if="message.attachments[0]" class="attachment-popover"> <Actions slot="trigger"> @@ -92,7 +93,10 @@ import AddressList from './AddressList' import {buildRecipients as buildReplyRecipients, buildReplySubject} from '../ReplyBuilder' import Error from './Error' import {getRandomMessageErrorMessage} from '../util/ErrorMessageFactory' +import {html, plain} from '../util/text' +import {isPgpgMessage} from '../crypto/pgp' import Itinerary from './Itinerary' +import MessageEncryptedBody from './MessageEncryptedBody' import MessageHTMLBody from './MessageHTMLBody' import MessagePlainTextBody from './MessagePlainTextBody' import Loading from './Loading' @@ -110,6 +114,7 @@ export default { Itinerary, Loading, MessageAttachments, + MessageEncryptedBody, MessageHTMLBody, MessagePlainTextBody, Modal, @@ -130,6 +135,12 @@ export default { } }, computed: { + from() { + return this.message.from[0]?.email + }, + isEncrypted() { + return isPgpgMessage(this.message.hasHtmlBody ? html(this.message.body) : plain(this.message.body)) + }, htmlUrl() { return generateUrl('/apps/mail/api/accounts/{accountId}/folders/{folderId}/messages/{id}/html', { accountId: this.message.accountId, diff --git a/src/components/MessageEncryptedBody.vue b/src/components/MessageEncryptedBody.vue new file mode 100644 index 000000000..5d0718fc0 --- /dev/null +++ b/src/components/MessageEncryptedBody.vue @@ -0,0 +1,42 @@ +<template> + <div> + <div v-if="mailvelope" id="mail-content"></div> + <span v-else>{{ t('mail', 'This message is encrypted with PGP. Install Mailvelope to decrypt it.') }}</span> + </div> +</template> + +<script> +import {getMailvelope} from '../crypto/mailvelope' + +export default { + name: 'MessageEncryptedBody', + props: { + body: { + type: String, + required: true, + }, + from: { + type: String, + required: false, + default: undefined, + }, + }, + data() { + return { + mailvelope: false, + } + }, + async mounted() { + this.mailvelope = await getMailvelope() + this.mailvelope.createDisplayContainer('#mail-content', this.body, undefined, { + senderAddress: this.from, + }) + }, +} +</script> + +<style scoped> +#mail-content { + height: 450px; +} +</style> diff --git a/src/components/NewMessageDetail.vue b/src/components/NewMessageDetail.vue index ddb6a211a..ae199c331 100644 --- a/src/components/NewMessageDetail.vue +++ b/src/components/NewMessageDetail.vue @@ -81,6 +81,7 @@ export default { cc: [], subject: buildReplySubject(message.subject), body: this.originalBody, + originalBody: this.originalBody, replyTo: message, } } else if (this.$route.params.messageUid === 'replyAll') { @@ -97,6 +98,7 @@ export default { cc: recipients.cc, subject: buildReplySubject(message.subject), body: this.originalBody, + originalBody: this.originalBody, replyTo: message, } } else { @@ -107,6 +109,7 @@ export default { cc: [], subject: buildForwardSubject(message.subject), body: this.originalBody, + originalBody: this.originalBody, forwardFrom: message, } } diff --git a/src/crypto/mailvelope.js b/src/crypto/mailvelope.js new file mode 100644 index 000000000..943c73a6b --- /dev/null +++ b/src/crypto/mailvelope.js @@ -0,0 +1,48 @@ +/* + * @copyright 2020 Christoph Wurst <christoph@winzerhof-wurst.at> + * + * @author 2020 Christoph Wurst <christoph@winzerhof-wurst.at> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +import logger from '../logger' + +let mailvelope + +const loadMailvelopeStatically = () => window.mailvelope + +const loadMailvelopeDynamically = () => + new Promise((res) => { + window.addEventListener('mailvelope', () => res(window.mailvelope), false) + }) + +export const getMailvelope = async () => { + if (mailvelope) { + return mailvelope + } + + mailvelope = loadMailvelopeStatically() + if (mailvelope) { + logger.debug('mailvelope found statically') + return mailvelope + } + + logger.debug('loading mailvelope dynamically') + mailvelope = await loadMailvelopeDynamically() + logger.debug('mailvelope found dynamically') + return mailvelope +} diff --git a/src/crypto/pgp.js b/src/crypto/pgp.js new file mode 100644 index 000000000..95d8b4fb5 --- /dev/null +++ b/src/crypto/pgp.js @@ -0,0 +1,29 @@ +/* + * @copyright 2020 Christoph Wurst <christoph@winzerhof-wurst.at> + * + * @author 2020 Christoph Wurst <christoph@winzerhof-wurst.at> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +import startsWith from 'lodash/fp/startsWith' + +/** + * @param {Text} message + * @return {boolean|*} + */ +export const isPgpgMessage = (message) => + message.format === 'plain' && message.value.startsWith('-----BEGIN PGP MESSAGE-----') diff --git a/src/tests/unit/crypto/mailvelope.spec.js b/src/tests/unit/crypto/mailvelope.spec.js new file mode 100644 index 000000000..a1e1c372f --- /dev/null +++ b/src/tests/unit/crypto/mailvelope.spec.js @@ -0,0 +1,49 @@ +/* + * @copyright 2020 Christoph Wurst <christoph@winzerhof-wurst.at> + * + * @author 2020 Christoph Wurst <christoph@winzerhof-wurst.at> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +import {getMailvelope} from '../../../crypto/mailvelope' + +describe('mailvelope', () => { + afterEach(() => { + delete window.mailvelope + }) + + it('loads statically', async () => { + window.mailvelope = { + mock: 3, + } + + const mailvelope = await getMailvelope() + + expect(mailvelope).to.deep.equal(window.mailvelope) + }) + + it('loads dynamically', async () => { + const p = getMailvelope() + window.mailvelope = { + mock: 3, + } + window.dispatchEvent(new Event('mailvelope')) + + const mailvelope = await p + expect(mailvelope).to.deep.equal(window.mailvelope) + }) +}) diff --git a/src/tests/unit/crypto/pgp.spec.js b/src/tests/unit/crypto/pgp.spec.js new file mode 100644 index 000000000..c62f72c76 --- /dev/null +++ b/src/tests/unit/crypto/pgp.spec.js @@ -0,0 +1,49 @@ +/* + * @copyright 2020 Christoph Wurst <christoph@winzerhof-wurst.at> + * + * @author 2020 Christoph Wurst <christoph@winzerhof-wurst.at> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +import {isPgpgMessage} from '../../../crypto/pgp' +import {html, plain} from '../../../util/text' + +describe('pgp', () => { + it('detects non-pgp messages', () => { + const messages = plain('Hi Alice') + + const isPgp = isPgpgMessage(messages) + + expect(isPgp).to.equal(false) + }) + + it('detects non-pgp HTML messages', () => { + const messages = html('Hi Alice') + + const isPgp = isPgpgMessage(messages) + + expect(isPgp).to.equal(false) + }) + + it('detects a pgp message', () => { + const message = plain('-----BEGIN PGP MESSAGE-----\nVersion: Mailvelope v4.3.1') + + const isPgp = isPgpgMessage(message) + + expect(isPgp).to.equal(true) + }) +}) |