diff options
author | Christoph Wurst <christoph@winzerhof-wurst.at> | 2018-11-05 14:05:07 +0300 |
---|---|---|
committer | Christoph Wurst <christoph@winzerhof-wurst.at> | 2018-11-05 18:50:34 +0300 |
commit | 881880e5def73a7ac846a0d3b96b64395bf22ba8 (patch) | |
tree | c3630762dc4ed5228bf111e7d99d2db2bf58e1d1 | |
parent | 20eb16ea63fff0bae159fc16bd0fc86f5299bd1b (diff) |
Fill reply recipients, subject and body
* Revive reply builder
* Prefill reply to and cc
* Revive HtmlHelper
* Revive reply bodies
Signed-off-by: Christoph Wurst <christoph@winzerhof-wurst.at>
-rw-r--r-- | package-lock.json | 160 | ||||
-rw-r--r-- | package.json | 5 | ||||
-rw-r--r-- | src/ReplyBuilder.js | 111 | ||||
-rw-r--r-- | src/components/Composer.vue | 23 | ||||
-rw-r--r-- | src/components/Envelope.vue | 2 | ||||
-rw-r--r-- | src/components/Message.vue | 43 | ||||
-rw-r--r-- | src/components/MessageHTMLBody.vue | 16 | ||||
-rw-r--r-- | src/components/Moment.vue | 4 | ||||
-rw-r--r-- | src/replybuilder.js | 96 | ||||
-rw-r--r-- | src/tests/setup.js | 4 | ||||
-rw-r--r-- | src/tests/unit/ReplyBuilder.spec.js | 229 | ||||
-rw-r--r-- | src/tests/unit/util/HtmlHelper.spec.js | 107 | ||||
-rw-r--r-- | src/util/HtmlHelper.js | 20 | ||||
-rw-r--r-- | src/util/htmlhelper.js | 30 | ||||
-rw-r--r-- | webpack.common.js | 3 | ||||
-rw-r--r-- | webpack.dev.js | 2 |
16 files changed, 665 insertions, 190 deletions
diff --git a/package-lock.json b/package-lock.json index efa3f7068..91ecf4d7f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1949,7 +1949,7 @@ }, "util": { "version": "0.10.3", - "resolved": "http://registry.npmjs.org/util/-/util-0.10.3.tgz", + "resolved": "https://registry.npmjs.org/util/-/util-0.10.3.tgz", "integrity": "sha1-evsa/lCAUkZInj23/g7TeTNqwPk=", "dev": true, "requires": { @@ -3152,7 +3152,7 @@ }, "cacache": { "version": "10.0.4", - "resolved": "http://registry.npmjs.org/cacache/-/cacache-10.0.4.tgz", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-10.0.4.tgz", "integrity": "sha512-Dph0MzuH+rTQzGPNT9fAnrPmMmjKfST6trxJeK7NQuHRaVw24VzPRWTmg9MpcwOVQZO0E1FBICUlFeNaKPIfHA==", "dev": true, "requires": { @@ -4110,12 +4110,33 @@ "esutils": "^2.0.2" } }, + "dom-serializer": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.1.0.tgz", + "integrity": "sha1-BzxpdUbOB4DOI75KKOKT5AvDDII=", + "requires": { + "domelementtype": "~1.1.1", + "entities": "~1.1.1" + }, + "dependencies": { + "domelementtype": { + "version": "1.1.3", + "resolved": "http://registry.npmjs.org/domelementtype/-/domelementtype-1.1.3.tgz", + "integrity": "sha1-vSh3PiZCiBrsUVRJJCmcXNgiGFs=" + } + } + }, "domain-browser": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/domain-browser/-/domain-browser-1.2.0.tgz", "integrity": "sha512-jnjyiM6eRyZl2H+W8Q/zLMA481hzi0eszAaBUzIVnmYVDBbnLxVNnfu1HgEBvCbL+71FrxMl3E6lpKH7Ge3OXA==", "dev": true }, + "domelementtype": { + "version": "1.3.0", + "resolved": "http://registry.npmjs.org/domelementtype/-/domelementtype-1.3.0.tgz", + "integrity": "sha1-sXrtguirWeUt2cGbF1bg/BhyBMI=" + }, "domexception": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/domexception/-/domexception-1.0.1.tgz", @@ -4125,6 +4146,23 @@ "webidl-conversions": "^4.0.2" } }, + "domhandler": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-2.4.2.tgz", + "integrity": "sha512-JiK04h0Ht5u/80fdLMCEmV4zkNh2BcoMFBmZ/91WtYZ8qVXSKjiw7fXMgFPnHcSZgOo3XdinHvmnDUeMf5R4wA==", + "requires": { + "domelementtype": "1" + } + }, + "domutils": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-1.7.0.tgz", + "integrity": "sha512-Lgd2XcJ/NjEw+7tFvfKxOzCYKZsdct5lczQ2ZaQY8Djz7pfAD3Gbp8ySJWtreII/vDlMVmxwa6pHmdxIYgttDg==", + "requires": { + "dom-serializer": "0", + "domelementtype": "1" + } + }, "dot-prop": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-4.2.0.tgz", @@ -4201,6 +4239,11 @@ "tapable": "^1.0.0" } }, + "entities": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/entities/-/entities-1.1.2.tgz", + "integrity": "sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w==" + }, "errno": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/errno/-/errno-0.1.7.tgz", @@ -4798,7 +4841,7 @@ }, "fecha": { "version": "2.3.3", - "resolved": "http://registry.npmjs.org/fecha/-/fecha-2.3.3.tgz", + "resolved": "https://registry.npmjs.org/fecha/-/fecha-2.3.3.tgz", "integrity": "sha512-lUGBnIamTAwk4znq5BcqsDaxSmZ9nDVJaij6NvRt/Tg4R69gERA+otPKbS86ROw9nxVMw2/mp1fnaiWqbs6Sdg==" }, "figures": { @@ -5009,8 +5052,7 @@ "ansi-regex": { "version": "2.1.1", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "aproba": { "version": "1.2.0", @@ -5031,14 +5073,12 @@ "balanced-match": { "version": "1.0.0", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "brace-expansion": { "version": "1.1.11", "bundled": true, "dev": true, - "optional": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -5053,20 +5093,17 @@ "code-point-at": { "version": "1.1.0", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "concat-map": { "version": "0.0.1", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "console-control-strings": { "version": "1.1.0", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "core-util-is": { "version": "1.0.2", @@ -5183,8 +5220,7 @@ "inherits": { "version": "2.0.3", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "ini": { "version": "1.3.5", @@ -5196,7 +5232,6 @@ "version": "1.0.0", "bundled": true, "dev": true, - "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -5211,7 +5246,6 @@ "version": "3.0.4", "bundled": true, "dev": true, - "optional": true, "requires": { "brace-expansion": "^1.1.7" } @@ -5219,14 +5253,12 @@ "minimist": { "version": "0.0.8", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "minipass": { "version": "2.2.4", "bundled": true, "dev": true, - "optional": true, "requires": { "safe-buffer": "^5.1.1", "yallist": "^3.0.0" @@ -5245,7 +5277,6 @@ "version": "0.5.1", "bundled": true, "dev": true, - "optional": true, "requires": { "minimist": "0.0.8" } @@ -5326,8 +5357,7 @@ "number-is-nan": { "version": "1.0.1", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "object-assign": { "version": "4.1.1", @@ -5339,7 +5369,6 @@ "version": "1.4.0", "bundled": true, "dev": true, - "optional": true, "requires": { "wrappy": "1" } @@ -5425,8 +5454,7 @@ "safe-buffer": { "version": "5.1.1", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "safer-buffer": { "version": "2.1.2", @@ -5462,7 +5490,6 @@ "version": "1.0.2", "bundled": true, "dev": true, - "optional": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -5482,7 +5509,6 @@ "version": "3.0.1", "bundled": true, "dev": true, - "optional": true, "requires": { "ansi-regex": "^2.0.0" } @@ -5526,14 +5552,12 @@ "wrappy": { "version": "1.0.2", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "yallist": { "version": "3.0.2", "bundled": true, - "dev": true, - "optional": true + "dev": true } } }, @@ -5902,8 +5926,7 @@ "he": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", - "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", - "dev": true + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==" }, "hmac-drbg": { "version": "1.0.1", @@ -5931,6 +5954,42 @@ "whatwg-encoding": "^1.0.1" } }, + "html-to-text": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-4.0.0.tgz", + "integrity": "sha512-QQl5EEd97h6+3crtgBhkEAO6sQnZyDff8DAeJzoSkOc1Dqe1UvTUZER0B+KjBe6fPZqq549l2VUhtracus3ndA==", + "requires": { + "he": "^1.0.0", + "htmlparser2": "^3.9.2", + "lodash": "^4.17.4", + "optimist": "^0.6.1" + } + }, + "htmlparser2": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.10.0.tgz", + "integrity": "sha512-J1nEUGv+MkXS0weHNWVKJJ+UrLfePxRWpN3C9bEi9fLxL2+ggW94DQvgYVXsaT30PGwYRIZKNZXuyMhp3Di4bQ==", + "requires": { + "domelementtype": "^1.3.0", + "domhandler": "^2.3.0", + "domutils": "^1.5.1", + "entities": "^1.1.1", + "inherits": "^2.0.1", + "readable-stream": "^3.0.6" + }, + "dependencies": { + "readable-stream": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.0.6.tgz", + "integrity": "sha512-9E1oLoOWfhSXHGv6QlwXJim7uNzd9EVlWK+21tCU9Ju/kR0/p2AZYPz4qSchgO8PlLIH4FpZYfzwS+rEksZjIg==", + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } + } + } + }, "http-signature": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", @@ -6041,8 +6100,7 @@ "inherits": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", - "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=", - "dev": true + "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=" }, "inquirer": { "version": "6.2.0", @@ -6761,7 +6819,7 @@ }, "meow": { "version": "3.7.0", - "resolved": "http://registry.npmjs.org/meow/-/meow-3.7.0.tgz", + "resolved": "https://registry.npmjs.org/meow/-/meow-3.7.0.tgz", "integrity": "sha1-cstmi0JSKCkKu/qFaJJYcwioAfs=", "dev": true, "requires": { @@ -6892,8 +6950,7 @@ "minimist": { "version": "0.0.8", "resolved": "http://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", - "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=", - "dev": true + "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=" }, "mississippi": { "version": "2.0.0", @@ -7570,6 +7627,22 @@ "mimic-fn": "^1.0.0" } }, + "optimist": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/optimist/-/optimist-0.6.1.tgz", + "integrity": "sha1-2j6nRob6IaGaERwybpDrFaAZZoY=", + "requires": { + "minimist": "~0.0.1", + "wordwrap": "~0.0.2" + }, + "dependencies": { + "wordwrap": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.3.tgz", + "integrity": "sha1-o9XabNXAvAAI03I0u68b7WMFkQc=" + } + } + }, "optionator": { "version": "0.8.2", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.2.tgz", @@ -8727,8 +8800,7 @@ "safe-buffer": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" }, "safe-regex": { "version": "1.1.0", @@ -9391,7 +9463,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, "requires": { "safe-buffer": "~5.1.0" } @@ -9946,8 +10017,7 @@ "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", - "dev": true + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" }, "util.promisify": { "version": "1.0.0", diff --git a/package.json b/package.json index ccad96162..fcdd99b86 100644 --- a/package.json +++ b/package.json @@ -10,14 +10,15 @@ "dev": "webpack --progress --watch --config webpack.dev.js", "lint": "$(npm bin)/eslint --ext .js,.vue js", "lint:autofix": "eslint --ext .js,.vue js --fix", - "test": "mocha-webpack --webpack-config webpack.test.js --require src/tests/setup.js src/tests/**/*.spec.js", - "test:watch": "mocha-webpack -w --webpack-config webpack.test.js --require src/tests/setup.js src/tests/**/*.spec.js" + "test": "mocha-webpack --webpack-config webpack.test.js --require src/tests/setup.js \"src/tests/**/*.spec.js\"", + "test:watch": "mocha-webpack -w --webpack-config webpack.test.js --require src/tests/setup.js \"src/tests/**/*.spec.js\"" }, "dependencies": { "@vue/babel-preset-app": "^3.1.1", "color-convert": "^1.9.3", "davclient.js": "^0.1.0", "exports-loader": "^0.7.0", + "html-to-text": "^4.0.0", "ical.js": "^1.2.2", "lodash": "^4.17.11", "md5": "^2.2.1", diff --git a/src/ReplyBuilder.js b/src/ReplyBuilder.js new file mode 100644 index 000000000..9ae5c53be --- /dev/null +++ b/src/ReplyBuilder.js @@ -0,0 +1,111 @@ +/** + * @copyright 2018 Christoph Wurst <christoph@winzerhof-wurst.at> + * + * @author 2018 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 _ from 'lodash' +import moment from 'moment' +import {getLocale} from 'nextcloud-server/dist/l10n' + +moment.locale(getLocale()) + +export const buildReplyBody = (original, from, date) => { + const start = '\n\n' + const body = '\n> ' + original.replace(/\n/g, '\n> ') + + if (from) { + const dateString = moment.unix(date).format('LLL') + return start + `"${from.label}" <${from.email}> – ${dateString}` + body + } else { + return start + body + } +} + +const RecipientType = Object.seal({ + None: 0, + To: 1, + Cc: 2 +}) + +export const buildRecipients = (envelope, ownAddress) => { + let recipientType = RecipientType.None + const isOwnAddress = a => a.email === ownAddress.email + const isNotOwnAddress = _.negate(isOwnAddress) + + // Locate why we received this envelope + // Can be in 'to', 'cc' or unknown + let replyingAddress = envelope.to.find(isOwnAddress) + if (!_.isUndefined(replyingAddress)) { + recipientType = RecipientType.To + } else { + replyingAddress = envelope.cc.find(isOwnAddress) + if (!_.isUndefined(replyingAddress)) { + recipientType = RecipientType.Cc + } else { + replyingAddress = ownAddress + } + } + + let to = [] + let cc = [] + if (recipientType === RecipientType.To) { + // Send to everyone except yourself plus the original sender + to = envelope.to.filter(isNotOwnAddress) + to = to.concat(envelope.from) + + // CC remains the same + cc = envelope.cc + } else if (recipientType === RecipientType.Cc) { + // Send to the same people plus the sender + to = envelope.to.concat(envelope.from) + + // All CC values are being kept except the replying address + cc = envelope.cc.filter(isNotOwnAddress) + } else { + // Send to the same recipient and the sender -> answer all + to = envelope.to + to = to.concat(envelope.from) + + // Keep CC values + cc = envelope.cc + } + + return { + to, + from: replyingAddress ? [replyingAddress] : [], + cc, + } +} + +// TODO: https://en.wikipedia.org/wiki/List_of_email_subject_abbreviations#Abbreviations_in_other_languages +const replyPrepends = [ + 're', +] + +/* + * Ref https://tools.ietf.org/html/rfc5322#section-3.6.5 + */ +export const buildReplySubject = original => { + if (replyPrepends.some(prepend => original.toLowerCase().startsWith(`${prepend}:`))) { + return original + } + + return `RE: ${original}` +} diff --git a/src/components/Composer.vue b/src/components/Composer.vue index 2ecd58cd1..a2bc8a4f6 100644 --- a/src/components/Composer.vue +++ b/src/components/Composer.vue @@ -96,7 +96,7 @@ import Vue from 'vue' import Loading from './Loading' - import ComposerAttachments from "./ComposerAttachments"; + import ComposerAttachments from './ComposerAttachments' Vue.use(Autosize) @@ -129,6 +129,10 @@ type: String, default: '', }, + body: { + type: String, + default: '' + }, draft: { type: Function, required: true, @@ -141,12 +145,12 @@ data () { return { hasCC: true, - selectedAlias: this.$route.params.accountId, + selectedAlias: this.$route.params.accountId, // TODO: fix for unified inbox toVal: this.addressListPlain(this.to), ccVal: this.addressListPlain(this.cc), bccVal: '', subjectVal: this.subject, - bodyVal: '', + bodyVal: this.body, attachments: [], noReply: false, message: '', @@ -167,12 +171,17 @@ return !_.isUndefined(this.replyTo) } }, - filters: { - - }, methods: { addressListPlain (addresses) { - return addresses.join('; ') + return addresses + .map(addr => { + if (addr.label && addr.label !== addr.email) { + return `"${addr.label}" <${addr.email}>` + } else { + return addr.email + } + }) + .join('; ') }, getMessageData () { return uid => { diff --git a/src/components/Envelope.vue b/src/components/Envelope.vue index 0092d175d..29b496a4e 100644 --- a/src/components/Envelope.vue +++ b/src/components/Envelope.vue @@ -40,7 +40,7 @@ {{ data.subject }} </div> <div class="app-content-list-item-details date"> - <Moment :timestamp="data.dateInt * 1000" /> + <Moment :timestamp="data.dateInt" /> </div> <Action class="app-content-list-item-menu" :actions="actions" /> diff --git a/src/components/Message.vue b/src/components/Message.vue index ee1884950..4b0ed2f6f 100644 --- a/src/components/Message.vue +++ b/src/components/Message.vue @@ -17,7 +17,8 @@ </div> <div class="mail-message-body"> <MessageHTMLBody v-if="message.hasHtmlBody" - :url="htmlUrl"/> + :url="htmlUrl" + @loaded="onHtmlBodyLoaded"/> <MessagePlainTextBody v-else :body="message.body" :signature="message.signature"/> @@ -25,7 +26,12 @@ <div id="reply-composer"></div> <input type="button" id="forward-button" value="Forward"> </div> - <Composer :replyTo="replyTo" + <Composer v-if="!message.hasHtmlBody || htmlBodyLoaded" + :to="replyRecipient.to" + :cc="replyRecipient.cc" + :subject="replySubject" + :body="replyBody" + :replyTo="replyTo" :send="sendReply" :draft="saveReplyDraft"/> </template> @@ -36,7 +42,13 @@ import { generateUrl } from 'nextcloud-server/dist/router' import AddressList from './AddressList' + import { + buildReplyBody, + buildRecipients as buildReplyRecipients, + buildReplySubject, + } from '../ReplyBuilder' import Composer from './Composer' + import {htmlToText} from '../util/HtmlHelper' import MessageHTMLBody from './MessageHTMLBody' import MessagePlainTextBody from './MessagePlainTextBody' import Loading from './Loading' @@ -57,6 +69,10 @@ return { loading: true, message: undefined, + htmlBodyLoaded: false, + replyRecipient: {}, + replySubject: '', + replyBody: '', }; }, computed: { @@ -86,6 +102,10 @@ fetchMessage () { this.loading = true this.message = undefined + this.replyRecipient = {} + this.replySubject = '' + this.replyBody = '' + this.htmlBodyLoaded = false const accountId = this.$route.params.accountId const folderId = this.$route.params.folderId @@ -98,6 +118,14 @@ id }).then(message => { this.message = message + + this.replyRecipient = buildReplyRecipients(message, {}) // TODO: own address + this.replySubject = buildReplySubject(message.subject) + + if (!message.hasHtmlBody) { + this.setReplyText(message.body) + } + this.loading = false }).then(() => { // TODO: add timeout so that message isn't flagged when only viewed @@ -122,6 +150,17 @@ }) }).catch(console.error.bind(this)) }, + setReplyText (text) { + this.replyBody = buildReplyBody( + htmlToText(text), + this.message.from[0], + this.message.dateInt, + ) + }, + onHtmlBodyLoaded (bodyString) { + this.setReplyText(bodyString) + this.htmlBodyLoaded = true + }, saveReplyDraft (data) { return saveDraft(data.account, data) .then(({uid}) => uid) diff --git a/src/components/MessageHTMLBody.vue b/src/components/MessageHTMLBody.vue index 5d8498805..fefc383e2 100644 --- a/src/components/MessageHTMLBody.vue +++ b/src/components/MessageHTMLBody.vue @@ -22,9 +22,12 @@ <script> export default { name: "MessageHTMLBody", - props: [ - 'url', - ], + props: { + url: { + type: String, + required: true, + }, + }, data () { return { loading: true @@ -32,7 +35,12 @@ }, methods: { onMessageFrameLoad () { - console.log('todo: resize', this.$refs.iframe) + const iframe = this.$refs.iframe + const iframeDoc = iframe.contentDocument || iframe.contentWindow.document + const iframeBody = iframeDoc.querySelectorAll('body')[0] + + console.log('todo: resize', iframe) + this.$emit('loaded', iframeBody.outerHTML) this.loading = false } } diff --git a/src/components/Moment.vue b/src/components/Moment.vue index 1a1ffe233..3e0ee066c 100644 --- a/src/components/Moment.vue +++ b/src/components/Moment.vue @@ -20,10 +20,10 @@ ], computed: { title () { - return moment.unix(this.timestamp / 1000).format(this.format || 'LLL'); + return moment.unix(this.timestamp).format(this.format || 'LLL'); }, formatted () { - return moment.unix(this.timestamp / 1000).fromNow(); + return moment.unix(this.timestamp).fromNow(); } } } diff --git a/src/replybuilder.js b/src/replybuilder.js deleted file mode 100644 index e9d67b681..000000000 --- a/src/replybuilder.js +++ /dev/null @@ -1,96 +0,0 @@ -/** - * @copyright 2017 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author 2017 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/>. - * - */ - -define(function(require) { - 'use strict'; - - var _ = require('underscore'); - - var RecipientType = Object.seal({ - None: 0, - To: 1, - Cc: 2 - }); - - var buildReply = function(message, messageBody) { - var recipientType = RecipientType.None; - var ownAddress = message.folder.account.get('emailAddress'); - var isOwnAddress = function(a) { - return a.email === ownAddress; - }; - var isNotOwnAddress = _.negate(isOwnAddress); - - // Locate why we received this message - // Can be in 'to', 'cc' or unknown - var replyingAddress = _.find(messageBody.get('to'), isOwnAddress); - if (!_.isUndefined(replyingAddress)) { - recipientType = RecipientType.To; - } else { - replyingAddress = _.find(messageBody.get('cc'), isOwnAddress); - if (!_.isUndefined(replyingAddress)) { - recipientType = RecipientType.Cc; - } else { - replyingAddress = { - label: ownAddress, - email: ownAddress - }; - } - } - - var to = []; - var cc = []; - if (recipientType === RecipientType.To) { - // Send to everyone except yourself plus the original sender - to = messageBody.get('to').filter(isNotOwnAddress); - to = to.concat(messageBody.get('from')); - - // CC remains the same - cc = messageBody.get('cc'); - } else if (recipientType === RecipientType.Cc) { - // Send to the same people plus the sender - to = messageBody.get('to').concat(messageBody.get('from')); - - // All CC values are being kept except the replying address - cc = messageBody.get('cc').filter(isNotOwnAddress); - } else { - // Send to the same recipient and the sender -> answer all - to = messageBody.get('to'); - to = to.concat(messageBody.get('from')); - - // Keep CC values - cc = messageBody.get('cc'); - } - - return { - to: to, - from: replyingAddress ? [replyingAddress] : [], - fromEmail: message.folder.account.get('emailAddress'), // TODO: alias? - cc: cc, - body: '' - }; - }; - - return { - buildReply: buildReply - }; - -}); diff --git a/src/tests/setup.js b/src/tests/setup.js index 18e56e1be..feb4c9bf6 100644 --- a/src/tests/setup.js +++ b/src/tests/setup.js @@ -21,3 +21,7 @@ require('jsdom-global')() global.expect = require('chai').expect + +global.OC = { + getLocale: () => 'en' +} diff --git a/src/tests/unit/ReplyBuilder.spec.js b/src/tests/unit/ReplyBuilder.spec.js new file mode 100644 index 000000000..445be5591 --- /dev/null +++ b/src/tests/unit/ReplyBuilder.spec.js @@ -0,0 +1,229 @@ +/* + * @copyright 2018 Christoph Wurst <christoph@winzerhof-wurst.at> + * + * @author 2018 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/>. + */ + +/* global expect */ + +/** + * @copyright 2017 Christoph Wurst <christoph@winzerhof-wurst.at> + * + * @author 2017 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 { + buildReplyBody, + buildRecipients, + buildReplySubject, +} from '../../ReplyBuilder' + +describe('ReplyBuilder', () => { + + it('creates a reply body without any sender', () => { + const body = 'Newsletter\nhello\ncheers' + const expectedReply = '\n\n\n> Newsletter\n> hello\n> cheers' + + const replyBody = buildReplyBody(body) + + expect(replyBody).to.equal(expectedReply) + }) + + it('creates a reply body', () => { + const body = 'Newsletter\nhello' + const expectedReply = '\n\n"Test User" <test@user.ru> – November 5, 2018 2:57 PM\n> Newsletter\n> hello' + + const replyBody = buildReplyBody( + body, + { + label: 'Test User', + email: 'test@user.ru', + }, + 1541426237, + ) + + expect(replyBody).to.equal(expectedReply) + }) + + let envelope + + beforeEach(function () { + envelope = {} + }) + + const createAddress = addr => { + return { + label: addr, + email: addr + } + } + + const assertSameAddressList = (l1, l2) => { + const rawL1 = l1.map(a => a.email) + const rawL2 = l2.map(a => a.email) + rawL1.sort() + rawL2.sort() + expect(rawL1).to.deep.equal(rawL2) + } + + // b -> a to a -as b + it('handles a one-on-one reply', () => { + const a = createAddress('a@domain.tld') + const b = createAddress('b@domain.tld') + envelope.from = [b] + envelope.to = [a] + envelope.cc = [] + + const reply = buildRecipients(envelope, a) + + assertSameAddressList(reply.from, [a]) + assertSameAddressList(reply.to, [b]) + assertSameAddressList(reply.cc, []) + }) + + it('handles simple group reply', () => { + const a = createAddress('a@domain.tld') + const b = createAddress('b@domain.tld') + const c = createAddress('c@domain.tld') + envelope.from = [a] + envelope.to = [b, c] + envelope.cc = [] + + var reply = buildRecipients(envelope, b) + + assertSameAddressList(reply.from, [b]) + assertSameAddressList(reply.to, [a, c]) + assertSameAddressList(reply.cc, []) + }) + + it('handles group reply with CC', () => { + const a = createAddress('a@domain.tld') + const b = createAddress('b@domain.tld') + const c = createAddress('c@domain.tld') + const d = createAddress('d@domain.tld') + envelope.from = [a] + envelope.to = [b, c] + envelope.cc = [d] + + const reply = buildRecipients(envelope, b) + + assertSameAddressList(reply.from, [b]) + assertSameAddressList(reply.to, [a, c]) + assertSameAddressList(reply.cc, [d]) + }) + + it('handles group reply of CC address', () => { + const a = createAddress('a@domain.tld') + const b = createAddress('b@domain.tld') + const c = createAddress('c@domain.tld') + const d = createAddress('d@domain.tld') + envelope.from = [a] + envelope.to = [b, c] + envelope.cc = [d] + + const reply = buildRecipients(envelope, d) + + assertSameAddressList(reply.from, [d]) + assertSameAddressList(reply.to, [a, b, c]) + assertSameAddressList(reply.cc, []) + }) + + it('handles group reply of CC address with many CCs', () => { + const a = createAddress('a@domain.tld') + const b = createAddress('b@domain.tld') + const c = createAddress('c@domain.tld') + const d = createAddress('d@domain.tld') + const e = createAddress('e@domain.tld') + envelope.from = [a] + envelope.to = [b, c] + envelope.cc = [d, e] + + const reply = buildRecipients(envelope, e) + + assertSameAddressList(reply.from, [e]) + assertSameAddressList(reply.to, [a, b, c]) + assertSameAddressList(reply.cc, [d]) + }) + + it('handles reply of message where the recipient is in the CC', () => { + const ali = createAddress('ali@domain.tld') + const bob = createAddress('bob@domain.tld') + const me = createAddress('c@domain.tld') + const dani = createAddress('d@domain.tld') + + envelope.from = [ali] + envelope.to = [bob] + envelope.cc = [me, dani] + + const reply = buildRecipients(envelope, me) + + assertSameAddressList(reply.from, [me]) + assertSameAddressList(reply.to, [ali, bob]) + assertSameAddressList(reply.cc, [dani]) + }) + + it('handles jan\'s reply to nina\'s mesage to a mailing list', () => { + const nina = createAddress('nina@nc.com') + const list = createAddress('list@nc.com') + const jan = createAddress('jan@nc.com') + + envelope.from = [nina] + envelope.to = [list] + envelope.cc = [] + + const reply = buildRecipients(envelope, jan) + + assertSameAddressList(reply.from, [jan]) + assertSameAddressList(reply.to, [nina, list]) + assertSameAddressList(reply.cc, []) + }) + + it('adds re: to a reply subject', () => { + const orig = 'Hello' + + const replySubject = buildReplySubject(orig) + + expect(replySubject).to.equal('RE: Hello') + }) + + it('does not stack subject re:\'s', () => { + const orig = 'Re: Hello' + + const replySubject = buildReplySubject(orig) + + expect(replySubject).to.equal('Re: Hello') + }) + +}) + diff --git a/src/tests/unit/util/HtmlHelper.spec.js b/src/tests/unit/util/HtmlHelper.spec.js new file mode 100644 index 000000000..b2498e09c --- /dev/null +++ b/src/tests/unit/util/HtmlHelper.spec.js @@ -0,0 +1,107 @@ +/* global expect */ + +/** + * @copyright 2017 Christoph Wurst <christoph@winzerhof-wurst.at> + * + * @author 2017 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 {htmlToText} from '../../../util/HtmlHelper' + +describe('HtmlHelper', () => { + it('preserves breaks', () => { + const html = 'line1<br>line2' + const expected = 'line1\nline2' + + const actual = htmlToText(html) + + expect(actual).to.equal(expected) + }) + + it('concats divs', () => { + const html = '<div>one</div><div>two</div>' + const expected = 'onetwo' + + const actual = htmlToText(html) + + expect(actual).to.equal(expected) + }) + + it('does not produce large number of line breaks for nested elements', () => { + const html = + '<div>' + + ' <div>' + + ' line1' + + ' </div>' + + '</div>' + + '<div>line2</div>' + const expected = ' line1 line2' + + const actual = htmlToText(html) + + expect(actual).to.equal(expected) + }) + + it('converts blocks to text', () => { + const html = '<div>hello</div>' + const expected = 'hello' + + const actual = htmlToText(html) + + expect(actual).to.equal(expected) + }) + + it('converts paragraphs to text', () => { + const html = '<p>hello</p>' + const expected = 'hello' + + const actual = htmlToText(html) + + expect(actual).to.equal(expected) + }) + + it('converts lists to text', () => { + const html = '<ul><li>one</li><li>two</li><li>three</li></ul>' + const expected = ' * one\n * two\n * three' + + const actual = htmlToText(html) + + expect(actual).to.equal(expected) + }) + + it('converts deeply nested elements to text', () => { + const html = '<html>' + + '<body><p>Hello!</p><p>this <i>is</i> <b>some</b> random <strong>text</strong></p></body>' + + '</html>' + const expected = 'Hello!\n\nthis is some random text' + + const actual = htmlToText(html) + + expect(actual).to.equal(expected) + }) + + it('does not leak internal redirection URLs', () => { + const html = '<a href="https://localhost/apps/mail/redirect?src=domain.tld">domain.tld</a>' + const expected = 'domain.tld' + + const actual = htmlToText(html) + + expect(actual).to.equal(expected) + }) +}) diff --git a/src/util/HtmlHelper.js b/src/util/HtmlHelper.js new file mode 100644 index 000000000..1ad9856f8 --- /dev/null +++ b/src/util/HtmlHelper.js @@ -0,0 +1,20 @@ +/** + * Mail + * + * This file is licensed under the Affero General Public License version 3 or + * later. See the COPYING file. + * + * @author Christoph Wurst <christoph@winzerhof-wurst.at> + * @copyright Christoph Wurst 2016 + */ + +import {fromString} from 'html-to-text' + +export const htmlToText = html => { + return fromString(html, { + noLinkBrackets: true, + ignoreHref: true, + ignoreImage: true, + wordwrap: 78 // 80 minus '> ' prefix for replies + }); +} diff --git a/src/util/htmlhelper.js b/src/util/htmlhelper.js deleted file mode 100644 index 28bc07a10..000000000 --- a/src/util/htmlhelper.js +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Mail - * - * This file is licensed under the Affero General Public License version 3 or - * later. See the COPYING file. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @copyright Christoph Wurst 2016 - */ - -define(function(require) { - 'use strict'; - - var $ = require('jquery'); - - var htmlToTextLib = require('html-to-text'); - - function htmlToText(html) { - return htmlToTextLib.fromString(html, { - noLinkBrackets: true, - ignoreHref: true, - ignoreImage: true, - wordwrap: 78 // 80 minus '> ' prefix for replies - }); - } - - return { - htmlToText: htmlToText - }; -}); diff --git a/webpack.common.js b/webpack.common.js index 73c84b30b..7a4ef5bca 100644 --- a/webpack.common.js +++ b/webpack.common.js @@ -8,6 +8,9 @@ module.exports = { publicPath: '/js/', filename: 'mail.js' }, + node: { + fs: 'empty' + }, module: { rules: [ { diff --git a/webpack.dev.js b/webpack.dev.js index 9f66562f9..db837f09b 100644 --- a/webpack.dev.js +++ b/webpack.dev.js @@ -8,5 +8,5 @@ module.exports = merge(common, { noInfo: true, overlay: true }, - devtool: '#cheap-source-map', + devtool: 'cheap-source-map', }) |