Skip to content

Commit

Permalink
Responsive mail iframe
Browse files Browse the repository at this point in the history
Signed-off-by: Richard Steinmetz <richard@steinmetz.cloud>
  • Loading branch information
st3iny committed Nov 6, 2020
1 parent 6828335 commit 237ce80
Show file tree
Hide file tree
Showing 14 changed files with 161 additions and 31 deletions.
7 changes: 7 additions & 0 deletions css/html-response.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
* {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Cantarell, Ubuntu, 'Helvetica Neue', Arial, 'Noto Color Emoji', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
}

body {
color: var(--color-main-text);
}
6 changes: 4 additions & 2 deletions lib/Controller/MessagesController.php
Original file line number Diff line number Diff line change
Expand Up @@ -336,12 +336,13 @@ public function getSource(int $id): JSONResponse {
* @TrapError
*
* @param int $id
* @param bool $plain do not inject scripts if true (default=false)
*
* @return HtmlResponse|TemplateResponse
*
* @throws ClientException
*/
public function getHtmlBody(int $id): Response {
public function getHtmlBody(int $id, bool $plain=false): Response {
try {
try {
$message = $this->mailManager->getMessage($this->currentUserId, $id);
Expand All @@ -364,7 +365,8 @@ public function getHtmlBody(int $id): Response {
true
)->getHtmlBody(
$id
)
),
$plain
);

// Harden the default security policy
Expand Down
27 changes: 19 additions & 8 deletions lib/Http/HtmlResponse.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,29 +25,40 @@

namespace OCA\Mail\Http;

use OCP\Util;
use OCP\AppFramework\Http\Response;

class HtmlResponse extends Response {

/** @var string */
private $content;

private $injectedStyles = <<<EOF
* { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Cantarell, Ubuntu, 'Helvetica Neue', Arial, 'Noto Color Emoji', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; }
EOF;
/** @var bool */
private $plain;


public function __construct(string $content) {
/**
* @param string $content message html content
* @param bool $plain do not inject scripts if true (default=false)
*/
public function __construct(string $content, bool $plain=false) {
parent::__construct();
$this->content = $content;
$this->plain = $plain;
}

/**
* Simply sets the headers and returns the file contents
* Inject scripts if not plain and return message html content.
*
* @return string the file contents
* @return string message html content
*/
public function render(): string {
return '<style>' . $this->injectedStyles . '</style>' . $this->content;
if ($this->plain) {
return $this->content;
}

$nonce = \OC::$server->getContentSecurityPolicyNonceManager()->getNonce();
$scriptSrc = Util::linkToAbsolute('mail', 'js/htmlresponse.js');
return '<script nonce="' . $nonce. '" src="' . $scriptSrc . '"></script>'
. $this->content;
}
}
5 changes: 5 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"dompurify": "^2.2.0",
"html-to-text": "^5.1.1",
"ical.js": "^1.4.0",
"iframe-resizer": "^4.2.11",
"js-base64": "^3.5.2",
"lodash": "^4.17.20",
"md5": "^2.3.0",
Expand Down
7 changes: 6 additions & 1 deletion src/components/Message.vue
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
<div v-if="message.itineraries.length > 0" class="message-itinerary">
<Itinerary :entries="message.itineraries" :message-id="message.messageId" />
</div>
<MessageHTMLBody v-if="message.hasHtmlBody" :url="htmlUrl" />
<MessageHTMLBody v-if="message.hasHtmlBody" :url="htmlUrl" :full-height="fullHeight" />
<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">
Expand Down Expand Up @@ -74,6 +74,11 @@ export default {
required: true,
type: Object,
},
fullHeight: {
required: false,
type: Boolean,
default: false,
},
},
computed: {
from() {
Expand Down
38 changes: 33 additions & 5 deletions src/components/MessageHTMLBody.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@
</button>
</div>
<div v-if="loading" class="icon-loading" />
<div id="message-container" :class="{hidden: loading}">
<iframe id="message-frame"
ref="iframe"
<div id="message-container" :class="{hidden: loading, scroll: !fullHeight}">
<iframe ref="iframe"
class="message-frame"
:title="t('mail', 'Message frame')"
:src="url"
seamless
Expand All @@ -19,6 +19,7 @@
</template>

<script>
import { iframeResizer } from 'iframe-resizer'
import PrintScout from 'printscout'
import logger from '../logger'
Expand All @@ -31,6 +32,11 @@ export default {
type: String,
required: true,
},
fullHeight: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
Expand All @@ -42,9 +48,26 @@ export default {
scout.on('beforeprint', this.onBeforePrint)
scout.on('afterprint', this.onAfterPrint)
},
mounted() {
iframeResizer({
onInit: () => {
const getCssVar = (key) => ({
[key]: getComputedStyle(document.documentElement).getPropertyValue(key),
})
// send css vars to client page
this.$refs.iframe.iFrameResizer.sendMessage({
cssVars: {
...getCssVar('--color-main-text'),
},
})
},
}, this.$refs.iframe)
},
beforeDestroy() {
scout.off('beforeprint', this.onBeforePrint)
scout.off('afterprint', this.onAfterPrint)
this.$refs.iframe.iFrameResizer.close()
},
methods: {
getIframeDoc() {
Expand Down Expand Up @@ -97,11 +120,16 @@ export default {
#message-container {
flex: 1;
min-height: 50vh;
display: flex;
// TODO: collapse quoted text and remove inner scrollbar
&.scroll {
max-height: 50vh;
overflow-y: auto;
}
}
#message-frame {
.message-frame {
width: 100%;
}
</style>
2 changes: 1 addition & 1 deletion src/components/NewMessageDetail.vue
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ export default {
if (message.hasHtmlBody) {
logger.debug('original message has HTML body')
const resp = await Axios.get(
generateUrl('/apps/mail/api/messages/{id}/html', {
generateUrl('/apps/mail/api/messages/{id}/html?plain=true', {
id,
})
)
Expand Down
1 change: 1 addition & 0 deletions src/components/Thread.vue
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
:envelope="env"
:mailbox-id="$route.params.mailboxId"
:expanded="expandedThreads.includes(env.databaseId)"
:full-height="thread.length === 1"
@move="onMove(env.databaseId)"
@toggleExpand="toggleExpand(env.databaseId)" />
</template>
Expand Down
10 changes: 9 additions & 1 deletion src/components/ThreadEnvelope.vue
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,10 @@
</div>
</div>
<Loading v-if="loading" />
<Message v-else-if="message" :envelope="envelope" :message="message" />
<Message v-else-if="message"
:envelope="envelope"
:message="message"
:full-height="fullHeight" />
<Error v-else-if="error"
:error="error && error.message ? error.message : t('mail', 'Not found')"
:message="errorMessage"
Expand Down Expand Up @@ -218,6 +221,11 @@ export default {
type: Boolean,
default: false,
},
fullHeight: {
required: false,
type: Boolean,
default: false,
},
},
data() {
return {
Expand Down
38 changes: 38 additions & 0 deletions src/html-response.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* @copyright 2020 Richard Steinmetz <richard@steinmetz.cloud>
*
* @author 2020 Richard Steinmetz <richard@steinmetz.cloud>
*
* @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/>.
*/

// injected styles
import '../css/html-response.css'

// iframe-resizer client script
import 'iframe-resizer/js/iframeResizer.contentWindow.js'
window.iFrameResizer = {
onMessage: (message) => {
if (!message.cssVars) {
return
}

// inject received css vars
Object.entries(message.cssVars).forEach(([key, val]) => {
document.documentElement.style.setProperty(key, val)
})
},
}
31 changes: 22 additions & 9 deletions tests/Unit/Controller/MessagesControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -177,39 +177,52 @@ public function testGetHtmlBody() {
$message->setUid(123);
$mailbox->setAccountId($accountId);
$mailbox->setName($folderId);
$this->mailManager->expects($this->once())
$this->mailManager->expects($this->exactly(3))
->method('getMessage')
->with($this->userId, $messageId)
->willReturn($message);
$this->mailManager->expects($this->once())
$this->mailManager->expects($this->exactly(3))
->method('getMailbox')
->with($this->userId, $mailboxId)
->willReturn($mailbox);
$this->accountService->expects($this->once())
$this->accountService->expects($this->exactly(3))
->method('find')
->with($this->equalTo($this->userId), $this->equalTo($accountId))
->will($this->returnValue($this->account));
$imapMessage = $this->createMock(IMAPMessage::class);
$this->mailManager->expects($this->once())
$this->mailManager->expects($this->exactly(3))
->method('getImapMessage')
->with($this->account, $mailbox, 123, true)
->willReturn($imapMessage);

$expectedResponse = new HtmlResponse('');
$expectedResponse->cacheFor(3600);
$expectedDefaultResponse = new HtmlResponse('');
$expectedDefaultResponse->cacheFor(3600);

$expectedPlainResponse = new HtmlResponse('', true);
$expectedPlainResponse->cacheFor(3600);

$expectedRichResponse = new HtmlResponse('', false);
$expectedRichResponse->cacheFor(3600);

if (class_exists('\OCP\AppFramework\Http\ContentSecurityPolicy')) {
$policy = new ContentSecurityPolicy();
$policy->allowEvalScript(false);
$policy->disallowScriptDomain('\'self\'');
$policy->disallowConnectDomain('\'self\'');
$policy->disallowFontDomain('\'self\'');
$policy->disallowMediaDomain('\'self\'');
$expectedResponse->setContentSecurityPolicy($policy);
$expectedDefaultResponse->setContentSecurityPolicy($policy);
$expectedPlainResponse->setContentSecurityPolicy($policy);
$expectedRichResponse->setContentSecurityPolicy($policy);
}

$actualResponse = $this->controller->getHtmlBody($messageId);
$actualDefaultResponse = $this->controller->getHtmlBody($messageId);
$actualPlainResponse = $this->controller->getHtmlBody($messageId, true);
$actualRichResponse = $this->controller->getHtmlBody($messageId, false);

$this->assertEquals($expectedResponse, $actualResponse);
$this->assertEquals($expectedDefaultResponse, $actualDefaultResponse);
$this->assertEquals($expectedPlainResponse, $actualPlainResponse);
$this->assertEquals($expectedRichResponse, $actualRichResponse);
}

public function testDownloadAttachment() {
Expand Down
16 changes: 13 additions & 3 deletions tests/Unit/Http/HtmlResponseTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@

use ChristophWurst\Nextcloud\Testing\TestCase;
use OCA\Mail\Http\HtmlResponse;
use OCP\Util;

class HtmlResponseTest extends TestCase {

Expand All @@ -34,9 +35,18 @@ class HtmlResponseTest extends TestCase {
* @param $contentType
*/
public function testIt($content) {
$resp = new HtmlResponse($content);
$injectedStyles = "<style>* { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Cantarell, Ubuntu, 'Helvetica Neue', Arial, 'Noto Color Emoji', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; }</style>";
$this->assertEquals($injectedStyles . $content, $resp->render());
$defaultResp = new HtmlResponse($content);
$plainResp = new HtmlResponse($content, true);
$richResp = new HtmlResponse($content, false);

$scriptSrcRegex = preg_quote(Util::linkToAbsolute('mail', 'js/htmlresponse.js'), '/');
$contentRegex = preg_quote($content, '/');
$responseRegex = '/<script nonce=".+" src="' . $scriptSrcRegex . '"><\/script>'
. $contentRegex . '/';

$this->assertMatchesRegularExpression($responseRegex, $defaultResp->render());
$this->assertEquals($content, $plainResp->render());
$this->assertMatchesRegularExpression($responseRegex, $richResp->render());
}

public function providesResponseData() {
Expand Down
3 changes: 2 additions & 1 deletion webpack.common.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ module.exports = {
autoredirect: path.join(__dirname, 'src/autoredirect.js'),
dashboard: path.join(__dirname, 'src/main-dashboard.js'),
mail: path.join(__dirname, 'src/main.js'),
settings: path.join(__dirname, 'src/main-settings')
settings: path.join(__dirname, 'src/main-settings'),
htmlresponse: path.join(__dirname, 'src/html-response.js'),
},
output: {
path: path.resolve(__dirname, 'js'),
Expand Down

0 comments on commit 237ce80

Please sign in to comment.