From 12b836326ade639d6da94a2e5cc5062cf488fb7d Mon Sep 17 00:00:00 2001 From: Daniel Kesselberg Date: Wed, 29 Jun 2022 18:57:44 +0200 Subject: [PATCH 01/19] Change column type for signature to longtext on mysql Signed-off-by: Daniel Kesselberg --- .../Version1140Date20220628174152.php | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 lib/Migration/Version1140Date20220628174152.php diff --git a/lib/Migration/Version1140Date20220628174152.php b/lib/Migration/Version1140Date20220628174152.php new file mode 100644 index 0000000000..c3091206b1 --- /dev/null +++ b/lib/Migration/Version1140Date20220628174152.php @@ -0,0 +1,126 @@ + + * + * @author Daniel Kesselberg + * + * @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 . + * + */ + +namespace OCA\Mail\Migration; + +use Closure; +use Doctrine\DBAL\Platforms\MySQLPlatform; +use Doctrine\DBAL\Types\Types; +use OCP\DB\ISchemaWrapper; +use OCP\IDBConnection; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +class Version1140Date20220628174152 extends SimpleMigrationStep { + private IDBConnection $connection; + + public function __construct(IDBConnection $connection) { + $this->connection = $connection; + } + + /** + * @param IOutput $output + * @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper` + * @param array $options + */ + public function preSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void { + /** @var ISchemaWrapper $schema */ + $schema = $schemaClosure(); + + /* + * Increase size limit for signature column. + * + * Initially the signature column was created with length = 1024. + * On mysql/mariadb the column is able to store 65535 bytes. + * + * To create a column with type longtext length must be null (or an integer bigger than 16777215): + * https://github.com/nextcloud/3rdparty/blob/2ae1a1d6f688ae8394d6559ee673fecbee975db4/doctrine/dbal/src/Platforms/MySQLPlatform.php#L237-L265 + * + * Length option is only relevant for MySQL/MariaDB. Postgre, Oracle and Sqlite don't have a + * concept like tinytext, mediumtext, text and longtext. + * + * Postgre: https://www.postgresql.org/docs/9.1/datatype-character.html + * Oracle: https://docs.oracle.com/en/database/oracle/oracle-database/19/sqlqr/Data-Types.html#GUID-219C338B-FE60-422A-B196-2F0A01CAD9A4 + * Sqlite: https://www.sqlite.org/datatype3.html / https://www.sqlite.org/limits.html + * + * To make it worse our doctrine version (3.1.6 for Nextcloud 24) is missing the logic to detect + * that the column length changed: https://github.com/doctrine/dbal/issues/2566 + */ + + if ($this->connection->getDatabasePlatform() instanceof MySQLPlatform) { + $alterQuery = "ALTER TABLE `%s` MODIFY `%s` longtext null;"; + + $accountsTable = $schema->getTable('mail_accounts'); + $accountsSignatureColumn = $accountsTable->getColumn('signature'); + + $this->connection->executeStatement( + sprintf($alterQuery, $accountsTable->getName(), $accountsSignatureColumn->getName()) + ); + + $aliasesTable = $schema->getTable('mail_aliases'); + $aliasesSignatureColumn = $accountsTable->getColumn('signature'); + + $this->connection->executeStatement( + sprintf($alterQuery, $aliasesTable->getName(), $aliasesSignatureColumn->getName()) + ); + + unset( + $accountsTable, + $accountsSignatureColumn, + $aliasesTable, + $aliasesSignatureColumn + ); + } + } + + /** + * @param IOutput $output + * @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper` + * @param array $options + * @return null|ISchemaWrapper + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + /** @var ISchemaWrapper $schema */ + $schema = $schemaClosure(); + + $accountsTable = $schema->getTable('mail_accounts'); + $aliasesTables = $schema->getTable('mail_aliases'); + + if (!$accountsTable->hasColumn('signature_mode')) { + $accountsTable->addColumn('signature_mode', Types::SMALLINT, [ + 'default' => 0, + ]); + } + + if (!$aliasesTables->hasColumn('signature_mode')) { + $aliasesTables->addColumn('signature_mode', Types::SMALLINT, [ + 'default' => 0, + ]); + } + + return $schema; + } +} From 0c53c8dce4ad91f0cab1a85d7b96a41b30a9fe3e Mon Sep 17 00:00:00 2001 From: Daniel Kesselberg Date: Wed, 29 Jun 2022 20:05:04 +0200 Subject: [PATCH 02/19] Add field signature mode to mapper Signed-off-by: Daniel Kesselberg --- lib/Db/Alias.php | 9 +++++++++ lib/Db/MailAccount.php | 9 +++++++++ tests/Integration/Db/MailAccountTest.php | 4 +++- 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/lib/Db/Alias.php b/lib/Db/Alias.php index 76ff18aa61..7eec7fb58b 100644 --- a/lib/Db/Alias.php +++ b/lib/Db/Alias.php @@ -38,8 +38,12 @@ * @method string|null getSignature() * @method void setProvisioningId(int $provisioningId) * @method int|null getProvisioningId() + * @method int getSignatureMode() + * @method void setSignatureMode(int $signatureMode) */ class Alias extends Entity implements JsonSerializable { + public const SIGNATURE_MODE_PLAIN = MailAccount::SIGNATURE_MODE_PLAIN; + public const SIGNATURE_MODE_HTML = MailAccount::SIGNATURE_MODE_HTML; /** @var int */ protected $accountId; @@ -56,11 +60,15 @@ class Alias extends Entity implements JsonSerializable { /** @var int|null */ protected $provisioningId; + /** @var integer */ + protected $signatureMode; + public function __construct() { $this->addType('accountId', 'int'); $this->addType('name', 'string'); $this->addType('alias', 'string'); $this->addType('provisioningId', 'int'); + $this->addType('signatureMode', 'int'); } public function isProvisioned(): bool { @@ -75,6 +83,7 @@ public function jsonSerialize() { 'alias' => $this->getAlias(), 'signature' => $this->getSignature(), 'provisioned' => $this->isProvisioned(), + 'signatureMode' => $this->getSignatureMode(), ]; } } diff --git a/lib/Db/MailAccount.php b/lib/Db/MailAccount.php index b24ca07297..0af4ca9be7 100644 --- a/lib/Db/MailAccount.php +++ b/lib/Db/MailAccount.php @@ -93,8 +93,13 @@ * @method void setSignatureAboveQuote(bool $signatureAboveQuote) * @method string getAuthMethod() * @method void setAuthMethod(string $method) + * @method int getSignatureMode() + * @method void setSignatureMode(int $signatureMode) */ class MailAccount extends Entity { + public const SIGNATURE_MODE_PLAIN = 0; + public const SIGNATURE_MODE_HTML = 1; + protected $userId; protected $name; protected $email; @@ -143,6 +148,8 @@ class MailAccount extends Entity { /** @var int|null */ protected $provisioningId; + /** @var int */ + protected $signatureMode; /** * @param array $params @@ -209,6 +216,7 @@ public function __construct(array $params = []) { $this->addType('sieveEnabled', 'boolean'); $this->addType('sievePort', 'integer'); $this->addType('signatureAboveQuote', 'boolean'); + $this->addType('signatureMode', 'int'); } /** @@ -235,6 +243,7 @@ public function toJson() { 'trashMailboxId' => $this->getTrashMailboxId(), 'sieveEnabled' => ($this->isSieveEnabled() === true), 'signatureAboveQuote' => ($this->isSignatureAboveQuote() === true), + 'signatureMode' => $this->getSignatureMode(), ]; if (!is_null($this->getOutboundHost())) { diff --git a/tests/Integration/Db/MailAccountTest.php b/tests/Integration/Db/MailAccountTest.php index 7eea6142a8..64e1bb5d77 100644 --- a/tests/Integration/Db/MailAccountTest.php +++ b/tests/Integration/Db/MailAccountTest.php @@ -71,6 +71,7 @@ public function testToAPI() { 'trashMailboxId' => null, 'sieveEnabled' => false, 'signatureAboveQuote' => false, + 'signatureMode' => null, ], $a->toJson()); } @@ -98,7 +99,8 @@ public function testMailAccountConstruct() { 'sentMailboxId' => null, 'trashMailboxId' => null, 'sieveEnabled' => false, - 'signatureAboveQuote' => false + 'signatureAboveQuote' => false, + 'signatureMode' => null, ]; $a = new MailAccount($expected); // TODO: fix inconsistency From ffb4d3e99419b497b298c110f4b05c84e04e5f6f Mon Sep 17 00:00:00 2001 From: Daniel Kesselberg Date: Tue, 12 Jul 2022 18:35:32 +0200 Subject: [PATCH 03/19] Load image and upload plugin for text editor Signed-off-by: Daniel Kesselberg --- src/components/SignatureSettings.vue | 3 +- src/components/TextEditor.vue | 83 ++++++++++++++++++++-------- 2 files changed, 62 insertions(+), 24 deletions(-) diff --git a/src/components/SignatureSettings.vue b/src/components/SignatureSettings.vue index c3a9cd1c4b..eff4c2aadf 100644 --- a/src/components/SignatureSettings.vue +++ b/src/components/SignatureSettings.vue @@ -182,7 +182,8 @@ export default { .ck.ck-editor__editable_inline { width: 100%; max-width: 78vw; - height: 100px; + height: 100%; + min-height: 100px; border-radius: var(--border-radius) !important; border: 1px solid var(--color-border) !important; box-shadow: none !important; diff --git a/src/components/TextEditor.vue b/src/components/TextEditor.vue index 13037a0f41..fd53647250 100644 --- a/src/components/TextEditor.vue +++ b/src/components/TextEditor.vue @@ -30,27 +30,32 @@ From acec2b8afd1310c0520d54e7347d97e111771d92 Mon Sep 17 00:00:00 2001 From: Daniel Kesselberg Date: Tue, 16 Aug 2022 22:26:18 +0200 Subject: [PATCH 10/19] Handle editor mode on alias change Signed-off-by: Daniel Kesselberg --- src/components/Composer.vue | 14 +++----------- src/components/TextEditor.vue | 2 +- 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/src/components/Composer.vue b/src/components/Composer.vue index 5365c8b802..23f98f9853 100644 --- a/src/components/Composer.vue +++ b/src/components/Composer.vue @@ -783,15 +783,6 @@ export default { this.onAliasChange(newAlias) } }, - // editorMode() { - // this.appendSignature = true - // }, - // bodyVal() { - // if (this.body.value === '') { - // this.appendSignature = true - // } - // this.handleAppendSignature() - // }, }, async beforeMount() { this.setAlias() @@ -999,7 +990,9 @@ export default { onAliasChange(alias) { logger.debug('changed alias', { alias }) this.selectedAlias = alias - this.editorMode = alias.editorMode + if (this.editorMode === EDITOR_MODE_TEXT && alias.editorMode === EDITOR_MODE_HTML) { + this.editorMode = alias.editorMode + } this.appendSignature = true this.handleAppendSignature(TRIGGER_CHANGE_ALIAS) }, @@ -1235,7 +1228,6 @@ export default { }, (decision) => { if (decision) { - this.bodyVal = toPlain(html(this.bodyVal)).value this.editorMode = EDITOR_MODE_TEXT } }, diff --git a/src/components/TextEditor.vue b/src/components/TextEditor.vue index 6d4976cda8..b919050c9b 100644 --- a/src/components/TextEditor.vue +++ b/src/components/TextEditor.vue @@ -218,7 +218,7 @@ export default { editor.editing.view.focus() } - logger.debug(`setting TextEditor contents to <${this.text}>`) + logger.debug(`setting TextEditor contents to <${this.sanitizedValue}>`) this.bus.$on('append-to-body-at-cursor', this.appendToBodyAtCursor) this.$emit('ready') From 2df77810de01a42ad21fdb57a28a83eeeeed0068 Mon Sep 17 00:00:00 2001 From: Daniel Kesselberg Date: Tue, 23 Aug 2022 15:21:58 +0200 Subject: [PATCH 11/19] Add ckeditor upload and image as dependency Signed-off-by: Daniel Kesselberg --- package-lock.json | 16 +++++++++------- package.json | 4 +++- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1e6a218f3c..981af46e3a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,12 +20,14 @@ "@ckeditor/ckeditor5-essentials": "~35.0.1", "@ckeditor/ckeditor5-font": "~35.0.1", "@ckeditor/ckeditor5-heading": "~35.0.1", + "@ckeditor/ckeditor5-image": "^35.0.1", "@ckeditor/ckeditor5-link": "~35.0.1", "@ckeditor/ckeditor5-list": "~35.0.1", "@ckeditor/ckeditor5-paragraph": "~35.0.1", "@ckeditor/ckeditor5-remove-format": "~35.0.1", "@ckeditor/ckeditor5-theme-lark": "~35.0.1", - "@ckeditor/ckeditor5-vue2": "3.0.0", + "@ckeditor/ckeditor5-upload": "^35.0.1", + "@ckeditor/ckeditor5-vue2": "^3.0.1", "@nextcloud/auth": "^2.0.0", "@nextcloud/axios": "^2.0.0", "@nextcloud/calendar-js": "^3.1.0", @@ -2622,9 +2624,9 @@ } }, "node_modules/@ckeditor/ckeditor5-vue2": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@ckeditor/ckeditor5-vue2/-/ckeditor5-vue2-3.0.0.tgz", - "integrity": "sha512-Q7ZkRZYZRhAN6Ll0WQoR49+yUPlrgWJxHw9Y6lwoU4RMAUvyZ180G50PL6FMg/Hi4hhwwCF4338EiCGKus1KIg==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@ckeditor/ckeditor5-vue2/-/ckeditor5-vue2-3.0.1.tgz", + "integrity": "sha512-vS9ffP3rOFgM8oeG9XVFD+UtcYAhkgFDfBHjswJuCgUM0Iw8uqLlCiDPbs4PeJsend8GcmmtNeFdcQaSPkOtpw==", "engines": { "node": ">=14.0.0", "npm": ">=5.7.1" @@ -16746,9 +16748,9 @@ } }, "@ckeditor/ckeditor5-vue2": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@ckeditor/ckeditor5-vue2/-/ckeditor5-vue2-3.0.0.tgz", - "integrity": "sha512-Q7ZkRZYZRhAN6Ll0WQoR49+yUPlrgWJxHw9Y6lwoU4RMAUvyZ180G50PL6FMg/Hi4hhwwCF4338EiCGKus1KIg==" + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@ckeditor/ckeditor5-vue2/-/ckeditor5-vue2-3.0.1.tgz", + "integrity": "sha512-vS9ffP3rOFgM8oeG9XVFD+UtcYAhkgFDfBHjswJuCgUM0Iw8uqLlCiDPbs4PeJsend8GcmmtNeFdcQaSPkOtpw==" }, "@ckeditor/ckeditor5-widget": { "version": "35.0.1", diff --git a/package.json b/package.json index f25c892e14..53a2f91ff5 100644 --- a/package.json +++ b/package.json @@ -26,12 +26,14 @@ "@ckeditor/ckeditor5-essentials": "~35.0.1", "@ckeditor/ckeditor5-font": "~35.0.1", "@ckeditor/ckeditor5-heading": "~35.0.1", + "@ckeditor/ckeditor5-image": "^35.0.1", "@ckeditor/ckeditor5-link": "~35.0.1", "@ckeditor/ckeditor5-list": "~35.0.1", "@ckeditor/ckeditor5-paragraph": "~35.0.1", "@ckeditor/ckeditor5-remove-format": "~35.0.1", "@ckeditor/ckeditor5-theme-lark": "~35.0.1", - "@ckeditor/ckeditor5-vue2": "3.0.0", + "@ckeditor/ckeditor5-upload": "^35.0.1", + "@ckeditor/ckeditor5-vue2": "^3.0.1", "@nextcloud/auth": "^2.0.0", "@nextcloud/axios": "^2.0.0", "@nextcloud/calendar-js": "^3.1.0", From 50ad334480d4b260ceeefc20b523de89f9878220 Mon Sep 17 00:00:00 2001 From: Daniel Kesselberg Date: Tue, 23 Aug 2022 22:57:12 +0200 Subject: [PATCH 12/19] Create a multipart/related message for html, text and inline images Signed-off-by: Daniel Kesselberg --- lib/Service/MailTransmission.php | 81 +------ lib/Service/MimeMessage.php | 173 +++++++++++++++ tests/Unit/Service/MailTransmissionTest.php | 34 +-- tests/Unit/Service/MimeMessageTest.php | 230 ++++++++++++++++++++ tests/data/mime-html-image.txt | 17 ++ tests/data/mime-html.txt | 17 ++ tests/data/mime-text.txt | 7 + tests/data/nextcloud.png | Bin 0 -> 21505 bytes 8 files changed, 463 insertions(+), 96 deletions(-) create mode 100644 lib/Service/MimeMessage.php create mode 100644 tests/Unit/Service/MimeMessageTest.php create mode 100644 tests/data/mime-html-image.txt create mode 100644 tests/data/mime-html.txt create mode 100644 tests/data/mime-text.txt create mode 100644 tests/data/nextcloud.png diff --git a/lib/Service/MailTransmission.php b/lib/Service/MailTransmission.php index 79c65bcd86..73b50c8c18 100644 --- a/lib/Service/MailTransmission.php +++ b/lib/Service/MailTransmission.php @@ -38,8 +38,6 @@ use Horde_Mime_Headers_Subject; use Horde_Mime_Mail; use Horde_Mime_Mdn; -use Horde_Mime_Part; -use Horde_Text_Filter; use OCA\Mail\Account; use OCA\Mail\Address; use OCA\Mail\AddressList; @@ -59,7 +57,6 @@ use OCA\Mail\Events\SaveDraftEvent; use OCA\Mail\Exception\AttachmentNotFoundException; use OCA\Mail\Exception\ClientException; -use OCA\Mail\Exception\InvalidDataUriException; use OCA\Mail\Exception\SentMailboxNotSetException; use OCA\Mail\Exception\ServiceException; use OCA\Mail\IMAP\IMAPClientFactory; @@ -191,59 +188,16 @@ public function sendMessage(NewMessageData $messageData, $mail = new Horde_Mime_Mail(); $mail->addHeaders($headers); - if ($messageData->isHtml()) { - $doc = new \DOMDocument(); - $doc->loadHTML($message->getContent(), LIBXML_HTML_NODEFDTD | LIBXML_HTML_NOIMPLIED); - - $uriParser = new DataUriParser(); - - $images = $doc->getElementsByTagName('img'); - for ($i = 0; $i < $images->count(); $i++) { - $image = $images->item($i); - if ($image === null) { - continue; - } - - $src = $image->getAttribute('src'); - if ($src === '') { - continue; - } - - try { - $dataUri = $uriParser->parse($src); - } catch (InvalidDataUriException $e) { - continue; - } - - $part = new Horde_Mime_Part(); - $part->setCharset($dataUri->getParameters()['charset']); - $part->setDisposition('inline'); - $part->setName('embedded_image_' . $i); - $part->setContents($dataUri->getData()); - $part->setType($dataUri->getMediaType()); - if ($dataUri->isBase64()) { - $part->setTransferEncoding('base64'); - } - - $cid = $part->setContentId(); - $mail->addMimePart($part); - - $image->setAttribute('src', 'cid:' . $cid); - } - - $htmlContent = $doc->saveHTML(); - $mail->setHtmlBody($htmlContent, null, false); - $mail->setBody(Horde_Text_Filter::filter($htmlContent, 'Html2text', - ['callback' => [$this, 'htmlToTextCallback']])); - } else { - $mail->setBody($message->getContent()); - } + $mimeMessage = new MimeMessage( + new DataUriParser() + ); - // Append local attachments - foreach ($message->getAttachments() as $attachment) { - $mail->addMimePart($attachment); - } + $mail->setBasePart($mimeMessage->build( + $messageData->isHtml(), + $message->getContent(), + $message->getAttachments() + )); $this->eventDispatcher->dispatchTyped( new BeforeMessageSentEvent($account, $messageData, $repliedToMessageId, $draft, $message, $mail) @@ -325,25 +279,6 @@ static function ($recipient) { } } - /** - * A callback for Horde_Text_Filter. - * - * The purpose of this callback is to overwrite the default behaviour - * of html2text filter to convert

Hello

=> Hello\n\n with - *

Hello

=> Hello\n. - * - * @param \DOMDocument $doc - * @param \DOMNode $node - * @return string|null non-null, add this text to the output and skip further processing of the node. - */ - public function htmlToTextCallback(\DOMDocument $doc, \DOMNode $node) { - if ($node instanceof \DOMElement && strtolower($node->tagName) === 'p') { - return $node->textContent . "\n"; - } - - return null; - } - /** * @param NewMessageData $message * @param Message|null $previousDraft diff --git a/lib/Service/MimeMessage.php b/lib/Service/MimeMessage.php new file mode 100644 index 0000000000..bc5d8a0e7c --- /dev/null +++ b/lib/Service/MimeMessage.php @@ -0,0 +1,173 @@ + + * + * Mail + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * 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, version 3, + * along with this program. If not, see + * + */ + +namespace OCA\Mail\Service; + +use DOMDocument; +use DOMNode; +use Horde_Mime_Part; +use Horde_Text_Filter; +use OCA\Mail\Exception\InvalidDataUriException; +use OCA\Mail\Service\DataUri\DataUriParser; + +class MimeMessage { + private DataUriParser $uriParser; + + public function __construct(DataUriParser $uriParser) { + $this->uriParser = $uriParser; + } + + /** + * @param bool $isHtml + * @param string $content + * @param Horde_Mime_Part[] $attachments + * @return void + */ + public function build(bool $isHtml, string $content, array $attachments): Horde_Mime_Part { + if ($isHtml) { + $doc = new DOMDocument(); + $doc->loadHTML($content, LIBXML_HTML_NODEFDTD | LIBXML_HTML_NOIMPLIED); + + $images = $doc->getElementsByTagName('img'); + $imageParts = []; + + for ($i = 0; $i < $images->count(); $i++) { + $image = $images->item($i); + if ($image === null) { + continue; + } + + $src = $image->getAttribute('src'); + if ($src === '') { + continue; + } + + try { + $dataUri = $this->uriParser->parse($src); + } catch (InvalidDataUriException $e) { + continue; + } + + $part = new Horde_Mime_Part(); + $part->setType($dataUri->getMediaType()); + $part->setCharset($dataUri->getParameters()['charset']); + $part->setName('embedded_image_' . $i); + $part->setDisposition('inline'); + if ($dataUri->isBase64()) { + $part->setTransferEncoding('base64'); + } + $part->setContents($dataUri->getData()); + + $cid = $part->setContentId(); + $imageParts[] = $part; + + $image->setAttribute('src', 'cid:' . $cid); + } + + $htmlContent = $doc->saveHTML(); + $textContent = Horde_Text_Filter::filter($htmlContent, 'Html2text', ['callback' => [$this, 'htmlToTextCallback']]); + + $alternativePart = new Horde_Mime_Part(); + $alternativePart->setType('multipart/alternative'); + + $htmlPart = new Horde_Mime_Part(); + $htmlPart->setType('text/html'); + $htmlPart->setCharset('UTF-8'); + $htmlPart->setContents($htmlContent); + $htmlPart->setDescription('HTML Version of Message'); + + $textPart = new Horde_Mime_Part(); + $textPart->setType('text/plain'); + $textPart->setCharset('UTF-8'); + $textPart->setContents($textContent); + $textPart->setDescription('Plaintext Version of Message'); + + /* + * RFC1341: In general, user agents that compose multipart/alternative entities should place the + * body parts in increasing order of preference, that is, with the preferred format last. + */ + $alternativePart[] = $textPart; + $alternativePart[] = $htmlPart; + + /* + * Wrap the multipart/alternative parts in multipart/related when inline images are found. + */ + if (count($imageParts) > 0) { + $bodyPart = new Horde_Mime_Part(); + $bodyPart->setType('multipart/related'); + $bodyPart[] = $alternativePart; + foreach ($imageParts as $imagePart) { + $bodyPart[] = $imagePart; + } + } else { + $bodyPart = $alternativePart; + } + } else { + $bodyPart = new Horde_Mime_Part(); + $bodyPart->setType('text/plain'); + $bodyPart->setCharset('UTF-8'); + $bodyPart->setContents($content); + } + + /* + * For attachments wrap the body (multipart/related, multipart/alternative or text/plain) in + * a multipart/mixed part. + */ + if (count($attachments) > 0) { + $basePart = new Horde_Mime_Part(); + $basePart->setType('multipart/mixed'); + $basePart[] = $bodyPart; + foreach ($attachments as $attachment) { + $basePart[] = $attachment; + } + } else { + $basePart = $bodyPart; + } + + /* + * To add the Mime-Version-Header + */ + $basePart->isBasePart(true); + + return $basePart; + } + + /** + * A callback for Horde_Text_Filter. + * + * The purpose of this callback is to overwrite the default behaviour + * of html2text filter to convert

Hello

=> Hello\n\n with + *

Hello

=> Hello\n. + * + * @param DOMDocument $doc + * @param DOMNode $node + * @return string|null non-null, add this text to the output and skip further processing of the node. + */ + public function htmlToTextCallback(DOMDocument $doc, DOMNode $node) { + if ($node instanceof \DOMElement && strtolower($node->tagName) === 'p') { + return $node->textContent . "\n"; + } + + return null; + } +} diff --git a/tests/Unit/Service/MailTransmissionTest.php b/tests/Unit/Service/MailTransmissionTest.php index 0030df0ec7..205c263510 100644 --- a/tests/Unit/Service/MailTransmissionTest.php +++ b/tests/Unit/Service/MailTransmissionTest.php @@ -53,8 +53,7 @@ use PHPUnit\Framework\MockObject\MockObject; use Psr\Log\LoggerInterface; -class MailTransmissionTest extends TestCase -{ +class MailTransmissionTest extends TestCase { /** @var Folder|MockObject */ private $userFolder; @@ -92,8 +91,7 @@ class MailTransmissionTest extends TestCase /** @var GroupsIntegration|MockObject */ private $groupsIntegration; - protected function setUp(): void - { + protected function setUp(): void { parent::setUp(); $this->userFolder = $this->createMock(Folder::class); @@ -125,8 +123,7 @@ protected function setUp(): void ); } - public function testSendNewMessage() - { + public function testSendNewMessage() { $mailAccount = new MailAccount(); $mailAccount->setUserId('testuser'); $mailAccount->setSentMailboxId(123); @@ -149,8 +146,7 @@ public function testSendNewMessage() $this->transmission->sendMessage($messageData, null); } - public function testSendMessageFromAlias() - { + public function testSendMessageFromAlias() { $mailAccount = new MailAccount(); $mailAccount->setUserId('testuser'); $mailAccount->setSentMailboxId(123); @@ -181,8 +177,7 @@ public function testSendMessageFromAlias() $this->transmission->sendMessage($messageData, null, $alias); } - public function testSendNewMessageWithMessageAsAttachment() - { + public function testSendNewMessageWithMessageAsAttachment() { $mailAccount = new MailAccount(); $mailAccount->setUserId('testuser'); $mailAccount->setSentMailboxId(123); @@ -250,8 +245,7 @@ public function testSendNewMessageWithMessageAsAttachment() $this->transmission->sendMessage($messageData, null); } - public function testSendNewMessageWithAttachmentsFromEmail() - { + public function testSendNewMessageWithAttachmentsFromEmail() { $mailAccount = new MailAccount(); $mailAccount->setUserId('testuser'); $mailAccount->setSentMailboxId(123); @@ -316,8 +310,7 @@ public function testSendNewMessageWithAttachmentsFromEmail() $this->transmission->sendMessage($messageData, null); } - public function testSendNewMessageWithCloudAttachments() - { + public function testSendNewMessageWithCloudAttachments() { $mailAccount = new MailAccount(); $mailAccount->setUserId('testuser'); $mailAccount->setSentMailboxId(123); @@ -364,8 +357,7 @@ public function testSendNewMessageWithCloudAttachments() $this->transmission->sendMessage($messageData, null); } - public function testReplyToAnExistingMessage() - { + public function testReplyToAnExistingMessage() { $mailAccount = new MailAccount(); $mailAccount->setUserId('testuser'); $mailAccount->setSentMailboxId(123); @@ -393,8 +385,7 @@ public function testReplyToAnExistingMessage() $this->transmission->sendMessage($messageData, $messageInReply->getMessageId()); } - public function testSaveDraft() - { + public function testSaveDraft() { $mailAccount = new MailAccount(); $mailAccount->setUserId('testuser'); $mailAccount->setDraftsMailboxId(123); @@ -428,8 +419,7 @@ public function testSaveDraft() $this->assertEquals(13, $newId); } - public function testSendLocalMessage(): void - { + public function testSendLocalMessage(): void { $mailAccount = new MailAccount(); $mailAccount->setId(10); $mailAccount->setUserId('testuser'); @@ -469,8 +459,7 @@ public function testSendLocalMessage(): void $this->transmission->sendLocalMessage(new Account($mailAccount), $message); } - public function testConvertInlineImageToAttachment() - { + public function testConvertInlineImageToAttachment() { $mailAccount = new MailAccount(); $mailAccount->setUserId('bob'); $mailAccount->setSentMailboxId(100); @@ -529,6 +518,5 @@ public function testConvertInlineImageToAttachment() $this->assertStringContainsString('img src="cid:', $rawMessage); $this->assertStringContainsString('Content-Type: image/png', $rawMessage); $this->assertStringContainsString('Content-Disposition: inline', $rawMessage); - } } diff --git a/tests/Unit/Service/MimeMessageTest.php b/tests/Unit/Service/MimeMessageTest.php new file mode 100644 index 0000000000..10214a1eb7 --- /dev/null +++ b/tests/Unit/Service/MimeMessageTest.php @@ -0,0 +1,230 @@ + + * + * Mail + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * 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, version 3, + * along with this program. If not, see + * + */ + +namespace OCA\Mail\Tests\Unit\Service; + +use ChristophWurst\Nextcloud\Testing\TestCase; +use Horde_Mime_Part; +use OCA\Mail\Account; +use OCA\Mail\AddressList; +use OCA\Mail\Db\MailAccount; +use OCA\Mail\Model\NewMessageData; +use OCA\Mail\Service\DataUri\DataUriParser; +use OCA\Mail\Service\MimeMessage; + +class MimeMessageTest extends TestCase { + private DataUriParser $uriParser; + private MimeMessage $mimeMessage; + private Account $account; + + protected function setUp(): void { + parent::setUp(); + + $this->uriParser = new DataUriParser(); + $this->mimeMessage = new MimeMessage($this->uriParser); + $this->account = new Account($this->createMock(MailAccount::class)); + } + + public function testTextPlain() { + $messageData = new NewMessageData( + $this->account, + new AddressList(), + new AddressList(), + new AddressList(), + 'Text message', + file_get_contents(__DIR__ . '/../../../tests/data/mime-text.txt'), + [], + false, + false + ); + + $part = $this->mimeMessage->build( + $messageData->isHtml(), + $messageData->getBody(), + [], + ); + + $this->assertEquals('text/plain', $part->getType()); + } + + public function testMultipartAlternative() { + $messageData = new NewMessageData( + $this->account, + new AddressList(), + new AddressList(), + new AddressList(), + 'Text and HTML message', + file_get_contents(__DIR__ . '/../../../tests/data/mime-html.txt'), + [], + true, + false + ); + + $part = $this->mimeMessage->build( + $messageData->isHtml(), + $messageData->getBody(), + [], + ); + + $this->assertEquals('multipart/alternative', $part->getType()); + + /** @var Horde_Mime_Part[] $subParts */ + $subParts = $part->getParts(); + $this->assertCount(2, $subParts); + $this->assertEquals('text/plain', $subParts[0]->getType()); + $this->assertEquals('text/html', $subParts[1]->getType()); + } + + public function testMultipartMixedAlternative() { + $messageData = new NewMessageData( + $this->account, + new AddressList(), + new AddressList(), + new AddressList(), + 'Text, HTML and Attachment message', + file_get_contents(__DIR__ . '/../../../tests/data/mime-html.txt'), + [], + true, + false + ); + + $attachment1 = $this->createAttachmentDetails( + 'nextcloud logo', + file_get_contents(__DIR__ . '/../../../tests/data/nextcloud.png'), + 'image/png' + ); + + $part = $this->mimeMessage->build( + $messageData->isHtml(), + $messageData->getBody(), + [$attachment1], + ); + + $this->assertEquals('multipart/mixed', $part->getType()); + + /** @var Horde_Mime_Part[] $subParts */ + $subParts = $part->getParts(); + $this->assertCount(2, $subParts); + + $alternativePart = $subParts[0]; + $this->assertEquals('multipart/alternative', $alternativePart->getType()); + + /** @var Horde_Mime_Part[] $alternativeSubParts */ + $alternativeSubParts = $alternativePart->getParts(); + $this->assertCount(2, $alternativeSubParts); + $this->assertEquals('text/plain', $alternativeSubParts[0]->getType()); + $this->assertEquals('text/html', $alternativeSubParts[1]->getType()); + + $attachmentPart = $subParts[1]; + $this->assertEquals('image/png', $attachmentPart->getType()); + $this->assertEquals('attachment', $attachmentPart->getDisposition()); + } + + public function testMultipartMixedRelated() { + $messageData = new NewMessageData( + $this->account, + new AddressList(), + new AddressList(), + new AddressList(), + 'Text, HTML and Attachment message', + file_get_contents(__DIR__ . '/../../../tests/data/mime-html-image.txt'), + [], + true, + false + ); + + $attachment1 = $this->createAttachmentDetails( + 'nextcloud logo', + file_get_contents(__DIR__ . '/../../../tests/data/nextcloud.png'), + 'image/png' + ); + + $attachment2 = $this->createAttachmentDetails( + 'sensitive animals logo', + file_get_contents(__DIR__ . '/../../../tests/data/test.txt'), + 'text/plain' + ); + + $part = $this->mimeMessage->build( + $messageData->isHtml(), + $messageData->getBody(), + [$attachment1, $attachment2], + ); + + $this->assertEquals('multipart/mixed', $part->getType()); + + /** @var Horde_Mime_Part[] $subParts */ + $subParts = $part->getParts(); + $this->assertCount(3, $subParts); + + $relatedPart = $subParts[0]; + $this->assertEquals('multipart/related', $relatedPart->getType()); + + /** @var Horde_Mime_Part[] $relatedSubParts */ + $relatedSubParts = $relatedPart->getParts(); + $this->assertCount(2, $relatedSubParts); + + $alternativePart = $relatedSubParts[0]; + $this->assertEquals('multipart/alternative', $alternativePart->getType()); + + /** @var Horde_Mime_Part[] $alternativeSubParts */ + $alternativeSubParts = $alternativePart->getParts(); + $this->assertCount(2, $alternativeSubParts); + $this->assertEquals('text/plain', $alternativeSubParts[0]->getType()); + $this->assertEquals('text/html', $alternativeSubParts[1]->getType()); + + $inlineImagePart = $relatedSubParts[1]; + $this->assertEquals('image/png', $inlineImagePart->getType()); + $this->assertEquals('inline', $inlineImagePart->getDisposition()); + + $inlineImageContentId = 'cid:' . $inlineImagePart->getContentId(); + $htmlBody = $alternativeSubParts[1]->getContents(); + $this->assertStringContainsString($inlineImageContentId, $htmlBody); + + $attachmentPart1 = $subParts[1]; + $this->assertEquals('image/png', $attachmentPart1->getType()); + $this->assertEquals('attachment', $attachmentPart1->getDisposition()); + + $attachmentPart2 = $subParts[2]; + $this->assertEquals('text/plain', $attachmentPart2->getType()); + $this->assertEquals('attachment', $attachmentPart2->getDisposition()); + } + + /** + * OCA\Mail\Model\Message::createAttachmentDetails + * + * @param string $name + * @param string $content + * @param string $mime + * @return void + */ + private function createAttachmentDetails(string $name, string $content, string $mime): Horde_Mime_Part { + $part = new Horde_Mime_Part(); + $part->setCharset('us-ascii'); + $part->setDisposition('attachment'); + $part->setName($name); + $part->setContents($content); + $part->setType($mime); + return $part; + } +} diff --git a/tests/data/mime-html-image.txt b/tests/data/mime-html-image.txt new file mode 100644 index 0000000000..c79ca737f0 --- /dev/null +++ b/tests/data/mime-html-image.txt @@ -0,0 +1,17 @@ + + + + + +

Hello,

+


+

+

a test message with plain text and html + 😎

+

Red dot +

+

Greetings!

+

Bob
+

+ + diff --git a/tests/data/mime-html.txt b/tests/data/mime-html.txt new file mode 100644 index 0000000000..b9445c2b54 --- /dev/null +++ b/tests/data/mime-html.txt @@ -0,0 +1,17 @@ + + + + + +

Hello,

+


+

+

a test message with plain text and html + 😎

+


+

+

Greetings!

+

Bob
+

+ + diff --git a/tests/data/mime-text.txt b/tests/data/mime-text.txt new file mode 100644 index 0000000000..46b4845344 --- /dev/null +++ b/tests/data/mime-text.txt @@ -0,0 +1,7 @@ +Hello, + +a simple text message here + +-- + +Jane Doe diff --git a/tests/data/nextcloud.png b/tests/data/nextcloud.png new file mode 100644 index 0000000000000000000000000000000000000000..12824cb1832060f663072420bddb2b5960fbfdf7 GIT binary patch literal 21505 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 literal 0 HcmV?d00001 From 0cfd3b740e8e123f69e3d92566ff7d25144d6474 Mon Sep 17 00:00:00 2001 From: Daniel Kesselberg Date: Wed, 24 Aug 2022 22:43:47 +0200 Subject: [PATCH 13/19] Adjust editor mode on alias change / Add warning on disable formatting 1) Adjust editor mode on alias change Changing an alias is simple when current and new alias use the same editor mode. Update alias object and trigger the signature plugin. When the current alias uses text but the new alias richtext we automatically change the editor mode to html. TextEditor uses :key="editorMode" to assign the instance to an editorMode. On :key change the old instance is destroyed and a new instance created. This emit another onEditorReady event and will trigger the signature plugin. To replace an existing signature changeSignature is true and set TRIGGER_CHANGE_ALIAS for the signature command. 2) Add warning on disable formatting When you compose a richtext message and disable formatting a warning is shown that formatting is lost. Enable formatting ActionCheckbox is now replaced by buttons with icons. Signed-off-by: Daniel Kesselberg --- src/components/Composer.vue | 88 +++++++++++++++++++---------------- src/components/TextEditor.vue | 8 ++-- 2 files changed, 52 insertions(+), 44 deletions(-) diff --git a/src/components/Composer.vue b/src/components/Composer.vue index 23f98f9853..786347961d 100644 --- a/src/components/Composer.vue +++ b/src/components/Composer.vue @@ -175,23 +175,11 @@
- - + + {{ t('mail', 'Enable formatting') }} - + + + + {{ t('mail', 'Disable formatting') }} + `) - this.bus.$on('append-to-body-at-cursor', this.appendToBodyAtCursor) - this.$emit('ready') + this.$emit('ready', editor) }, onEditorInput(text) { logger.debug(`TextEditor input changed to <${text}>`) @@ -240,7 +238,7 @@ export default { throw new Error('Impossible to execute a command before editor is ready.') } }, - }, + }, } From 9aafcc543599476bac347ad50cf638d9eb9017f1 Mon Sep 17 00:00:00 2001 From: Daniel Kesselberg Date: Wed, 24 Aug 2022 00:11:11 +0200 Subject: [PATCH 14/19] Tests for onEditorInput / editorExecute / signature Signed-off-by: Daniel Kesselberg --- src/components/TextEditor.vue | 2 +- .../unit/components/SignaturePlugin.spec.js | 93 +++++++++++++++++++ src/tests/unit/components/TextEditor.spec.js | 77 ++++++++++++++- src/tests/virtualtesteditor.js | 40 ++++++++ 4 files changed, 209 insertions(+), 3 deletions(-) create mode 100644 src/tests/unit/components/SignaturePlugin.spec.js create mode 100644 src/tests/virtualtesteditor.js diff --git a/src/components/TextEditor.vue b/src/components/TextEditor.vue index 4a151a3a0a..32af8dd3d8 100644 --- a/src/components/TextEditor.vue +++ b/src/components/TextEditor.vue @@ -238,7 +238,7 @@ export default { throw new Error('Impossible to execute a command before editor is ready.') } }, - }, + }, } diff --git a/src/tests/unit/components/SignaturePlugin.spec.js b/src/tests/unit/components/SignaturePlugin.spec.js new file mode 100644 index 0000000000..395c2e19c8 --- /dev/null +++ b/src/tests/unit/components/SignaturePlugin.spec.js @@ -0,0 +1,93 @@ +/* + * @copyright 2022 Daniel Kesselberg + * + * @author 2022 Daniel Kesselberg + * + * @license AGPL-3.0-or-later + * + * 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 . + */ + +import VirtualTestEditor from '../../virtualtesteditor' +import ParagraphPlugin from '@ckeditor/ckeditor5-paragraph/src/paragraph' +import SignaturePlugin from '../../../ckeditor/signature/SignaturePlugin' +import { + TRIGGER_CHANGE_ALIAS, + TRIGGER_EDITOR_READY, +} from '../../../ckeditor/signature/InsertSignatureCommand' + +describe('SignaturePlugin', () => { + + describe('TRIGGER_EDITOR_READY', () => { + + it('Add signature to content', async() => { + const text = '

bonjour bonjour

' + const expected = '

bonjour bonjour

-- 

 

Jane Doe

' + + const editor = await VirtualTestEditor.create({ + initialData: text, + plugins: [ParagraphPlugin, SignaturePlugin], + }) + + editor.execute('insertSignature', + TRIGGER_EDITOR_READY, + '

Jane Doe

', + false, + ) + + expect(editor.getData()).toEqual(expected) + }) + + it('Keep existing signature', async() => { + const text = '

bonjour bonjour

-- 

Bob

' + + const editor = await VirtualTestEditor.create({ + initialData: text, + plugins: [ParagraphPlugin, SignaturePlugin], + }) + + editor.execute('insertSignature', + TRIGGER_EDITOR_READY, + '

Jane Doe

', + false, + ) + + expect(editor.getData()).toEqual(text) + }) + + }) + + describe('TRIGGER_CHANGE_ALIAS', () => { + + it('Replace existing signature', async() => { + const text = '

bonjour bonjour

-- 

Bob

' + const expected = '

bonjour bonjour

-- 

 

Jane Doe

' + + const editor = await VirtualTestEditor.create({ + initialData: text, + plugins: [ParagraphPlugin, SignaturePlugin], + }) + + editor.execute('insertSignature', + TRIGGER_CHANGE_ALIAS, + '

Jane Doe

', + false, + ) + + expect(editor.getData()).toEqual(expected) + }) + + }) + +}) diff --git a/src/tests/unit/components/TextEditor.spec.js b/src/tests/unit/components/TextEditor.spec.js index 0291480b43..e386c54f45 100644 --- a/src/tests/unit/components/TextEditor.spec.js +++ b/src/tests/unit/components/TextEditor.spec.js @@ -19,12 +19,14 @@ * along with this program. If not, see . */ -import { shallowMount, createLocalVue } from '@vue/test-utils' +import {createLocalVue, shallowMount} from '@vue/test-utils' import Vue from 'vue' import Vuex from 'vuex' import Nextcloud from '../../../mixins/Nextcloud' import TextEditor from '../../../components/TextEditor' +import VirtualTestEditor from '../../virtualtesteditor' +import ParagraphPlugin from '@ckeditor/ckeditor5-paragraph/src/paragraph' const localVue = createLocalVue() @@ -38,9 +40,80 @@ describe('TextEditor', () => { localVue, propsData: { value: 'bonjour', - bus: new Vue() + bus: new Vue(), }, }) }) + it('throw when editor not ready', async() => { + const wrapper = shallowMount(TextEditor, { + localVue, + propsData: { + value: 'bonjour', + bus: new Vue(), + }, + }) + + const error = new Error( + 'Impossible to execute a command before editor is ready.') + expect(() => wrapper.vm.editorExecute('insertSignature', {})). + toThrowError(error) + }) + + it('emit event on input', async() => { + const wrapper = shallowMount(TextEditor, { + localVue, + propsData: { + value: 'bonjour', + bus: new Vue(), + }, + }) + + wrapper.vm.onEditorInput('bonjour bonjour') + + expect(wrapper.emitted().input[0]).toBeTruthy() + expect(wrapper.emitted().input[0]).toEqual(['bonjour bonjour']) + }) + + it('emit event on ready', async() => { + const wrapper = shallowMount(TextEditor, { + localVue, + propsData: { + value: 'bonjour', + bus: new Vue(), + }, + }) + + const editor = await VirtualTestEditor.create({ + initialData: '

bonjour bonjour

', + plugins: [ParagraphPlugin], + }) + + wrapper.vm.onEditorReady(editor) + + expect(wrapper.emitted().ready[0]).toBeTruthy() + }) + + it('register conversion to add margin: 0px to every

element', + async() => { + const wrapper = shallowMount(TextEditor, { + localVue, + propsData: { + value: '', + bus: new Vue(), + }, + }) + + const editor = await VirtualTestEditor.create({ + initialData: '

bonjour bonjour

', + plugins: [ParagraphPlugin], + }) + + wrapper.vm.onEditorReady(editor) + + expect(wrapper.emitted().ready[0]).toBeTruthy() + expect(wrapper.emitted().ready[0][0].getData()). + toEqual('

bonjour bonjour

') + }) + }) diff --git a/src/tests/virtualtesteditor.js b/src/tests/virtualtesteditor.js new file mode 100644 index 0000000000..5bbaf00ac4 --- /dev/null +++ b/src/tests/virtualtesteditor.js @@ -0,0 +1,40 @@ +/** + * @license Copyright (c) 2003-2022, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +import Editor from '@ckeditor/ckeditor5-core/src/editor/editor'; +import DataApiMixin from '@ckeditor/ckeditor5-core/src/editor/utils/dataapimixin'; +import mix from '@ckeditor/ckeditor5-utils/src/mix'; + +/** + * A simple editor implementation useful for testing the engine part of the features. + * It contains full data pipepilne and the engine pipeline but without rendering to DOM. + * + * Should work in Node.js. If not now, then in the future :). + */ +export default class VirtualTestEditor extends Editor { + constructor( config ) { + super( config ); + + // Create the ("main") root element of the model tree. + this.model.document.createRoot(); + } + + static create( config = {} ) { + return new Promise( resolve => { + const editor = new this( config ); + + resolve( + editor.initPlugins() + .then( () => editor.data.init( config.initialData || '' ) ) + .then( () => { + editor.fire( 'ready' ); + return editor; + } ) + ); + } ); + } +} + +mix( VirtualTestEditor, DataApiMixin ); From 10d68c8a6b6a3bf004af806023ae4933942e5ce3 Mon Sep 17 00:00:00 2001 From: Daniel Kesselberg Date: Tue, 30 Aug 2022 14:32:01 +0200 Subject: [PATCH 15/19] Allow bmp, tiff and webp Otherwise it's not possible to open a draft with a webp image Signed-off-by: Daniel Kesselberg --- lib/Service/Html.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/Service/Html.php b/lib/Service/Html.php index 09e755afc2..5c12582fbb 100755 --- a/lib/Service/Html.php +++ b/lib/Service/Html.php @@ -154,7 +154,12 @@ public function sanitizeHtmlMailBody(string $mailBody, array $messageParameters, $uriSchemeRegistry = HTMLPurifier_URISchemeRegistry::instance(); $uriSchemeRegistry->register('cid', new CidURIScheme()); - $uriSchemeRegistry->register('data', new \HTMLPurifier_URIScheme_data()); + + $uriSchemaData = new \HTMLPurifier_URIScheme_data(); + $uriSchemaData->allowed_types['image/bmp'] = true; + $uriSchemaData->allowed_types['image/tiff'] = true; + $uriSchemaData->allowed_types['image/webp'] = true; + $uriSchemeRegistry->register('data', $uriSchemaData); $purifier = new HTMLPurifier($config); From 09667597a73e7df8a45b5f1c62d07e2e2a3bab54 Mon Sep 17 00:00:00 2001 From: Daniel Kesselberg Date: Tue, 30 Aug 2022 22:07:04 +0200 Subject: [PATCH 16/19] Sanitized value in onEditorInput DOMPurify.sanitize may re-order the attributes. For example: Test to Test> CKEditor always adds the alt attribute after src attribute. Internal (CKEditor) and external (TextEditor) are different and every keystroke will emit a second event. Signed-off-by: Daniel Kesselberg --- src/components/TextEditor.vue | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/components/TextEditor.vue b/src/components/TextEditor.vue index 32af8dd3d8..a4a1d89de9 100644 --- a/src/components/TextEditor.vue +++ b/src/components/TextEditor.vue @@ -138,9 +138,7 @@ export default { }, computed: { sanitizedValue() { - return DOMPurify.sanitize(this.value, { - FORBID_TAGS: ['style'], - }) + return this.sanitizeValue(this.value) }, }, beforeMount() { @@ -222,6 +220,7 @@ export default { this.$emit('ready', editor) }, onEditorInput(text) { + text = this.sanitizeValue(text) logger.debug(`TextEditor input changed to <${text}>`) this.$emit('input', text) }, @@ -238,6 +237,11 @@ export default { throw new Error('Impossible to execute a command before editor is ready.') } }, + sanitizeValue(text) { + return DOMPurify.sanitize(text, { + FORBID_TAGS: ['style'], + }) + }, }, } From e07de49980d4a0be595dd0556ec36b956c8498d0 Mon Sep 17 00:00:00 2001 From: Daniel Kesselberg Date: Tue, 30 Aug 2022 22:27:41 +0200 Subject: [PATCH 17/19] Move imageUpload forward Signed-off-by: Daniel Kesselberg --- src/components/TextEditor.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/TextEditor.vue b/src/components/TextEditor.vue index a4a1d89de9..bfc3fdd965 100644 --- a/src/components/TextEditor.vue +++ b/src/components/TextEditor.vue @@ -111,6 +111,7 @@ export default { 'bold', 'italic', 'fontColor', + 'imageUpload', 'alignment', 'bulletedList', 'numberedList', @@ -119,7 +120,6 @@ export default { 'strikethrough', 'link', 'removeFormat', - 'imageUpload', ]) } From ea90bc1f4c668592e9d7d0ee1dfdf787077cd60b Mon Sep 17 00:00:00 2001 From: Daniel Kesselberg Date: Fri, 2 Sep 2022 15:37:30 +0200 Subject: [PATCH 18/19] Move DOMPurify.sanitize to showMessageComposer DOMPurify.sanitize may take a while. Save some sanitize executions by moving the sanitization to an earlier stage. Signed-off-by: Daniel Kesselberg --- src/components/TextEditor.vue | 15 ++------------- src/store/actions.js | 9 +++++++++ 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/src/components/TextEditor.vue b/src/components/TextEditor.vue index bfc3fdd965..3a2a9e61d3 100644 --- a/src/components/TextEditor.vue +++ b/src/components/TextEditor.vue @@ -22,7 +22,7 @@