Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions src/components/BlockedContentWarning.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->

<template>
<div class="blocked-content-warning">
<div class="blocked-content-warning__text">
<IconImageOff :size="20" />
{{ t('mail', 'The images have been blocked to protect your privacy.') }}
</div>
<NcActions variant="secondary" :menu-name="t('mail', 'Show images')">
<NcActionButton @click="$emit('show')">
<template #icon>
<IconImage :size="20" />
</template>
{{ t('mail', 'Show images temporarily') }}
</NcActionButton>
<NcActionButton
v-if="sender"
@click="$emit('trust-sender')">
<template #icon>
<IconMail :size="20" />
</template>
{{ t('mail', 'Always show images from {sender}', { sender }) }}
</NcActionButton>
<NcActionButton
v-if="domain"
@click="$emit('trust-domain')">
<template #icon>
<IconDomain :size="20" />
</template>
{{ t('mail', 'Always show images from {domain}', { domain }) }}
</NcActionButton>
</NcActions>
</div>
</template>

<script>
import { NcActionButton, NcActions } from '@nextcloud/vue'
import IconDomain from 'vue-material-design-icons/Domain.vue'
import IconMail from 'vue-material-design-icons/EmailOutline.vue'
import IconImageOff from 'vue-material-design-icons/ImageOffOutline.vue'
import IconImage from 'vue-material-design-icons/ImageSizeSelectActual.vue'

export default {
name: 'BlockedContentWarning',
components: {
IconDomain,
IconImage,
IconImageOff,
IconMail,
NcActionButton,
NcActions,
},

props: {
sender: {
type: String,
default: null,
},

domain: {
type: String,
default: null,
},
},
}
</script>

<style lang="scss" scoped>
.blocked-content-warning {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--default-grid-baseline);
padding: calc(var(--default-grid-baseline) * 2);
border-radius: var(--border-radius-element);
background-color: var(--color-background-dark);
color: var(--color-main-text);
text-align: start;

&__text {
display: flex;
align-items: center;
gap: var(--default-grid-baseline);
}
}
</style>
111 changes: 35 additions & 76 deletions src/components/MessageHTMLBody.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,60 +4,39 @@
-->
<template>
<div class="html-message-body">
<MdnRequest :message="message" />
<NeedsTranslationInfo
v-if="detectedForeignLanguage"
:is-html="true"
@translate="$emit('translate', detectedForeignLanguage)" />
<div v-if="hasBlockedContent" id="mail-message-has-blocked-content" style="color: #000000">
{{ t('mail', 'The images have been blocked to protect your privacy.') }}
<NcActions variant="tertiary" :menu-name="t('mail', 'Show images')">
<NcActionButton @click="displayIframe">
<template #icon>
<IconImage :size="20" />
</template>
{{ t('mail', 'Show images temporarily') }}
</NcActionButton>
<NcActionButton
v-if="sender"
@click="onShowBlockedContent">
<template #icon>
<IconMail :size="20" />
</template>
{{ t('mail', 'Always show images from {sender}', { sender }) }}
</NcActionButton>
<NcActionButton
v-if="domain"
@click="onShowBlockedContentForDomain">
<template #icon>
<IconDomain :size="20" />
</template>
{{ t('mail', 'Always show images from {domain}', { domain }) }}
</NcActionButton>
</NcActions>
</div>
<div id="message-container" :class="{ scroll: !fullHeight }">
<!-- allow-scripts: the server-injected iframe-resizer child must run to size the frame to its content.
allow-same-origin: parent JS accesses contentDocument directly (image unblocking, resize, print).
allow-popups + allow-popups-to-escape-sandbox: email links must open as normal tabs, not sandboxed ones. -->
<iframe
ref="iframe"
class="message-frame"
:title="t('mail', 'Message frame')"
:src="url"
seamless
sandbox="allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox"
@load="onMessageFrameLoad" />
<BlockedContentWarning
v-if="hasBlockedContent"
:sender="sender"
:domain="domain"
@show="displayIframe"
@trust-sender="onShowBlockedContent"
@trust-domain="onShowBlockedContentForDomain" />
<div class="html-message-body__content">
<MdnRequest :message="message" />
<NeedsTranslationInfo
v-if="detectedForeignLanguage"
:is-html="true"
@translate="$emit('translate', detectedForeignLanguage)" />
<div id="message-container" :class="{ scroll: !fullHeight }">
<!-- allow-scripts: the server-injected iframe-resizer child must run to size the frame to its content.
allow-same-origin: parent JS accesses contentDocument directly (image unblocking, resize, print).
allow-popups + allow-popups-to-escape-sandbox: email links must open as normal tabs, not sandboxed ones. -->
<iframe
ref="iframe"
class="message-frame"
:title="t('mail', 'Message frame')"
:src="url"
seamless
sandbox="allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox"
@load="onMessageFrameLoad" />
</div>
</div>
</div>
</template>

<script>
import iframeResize from '@iframe-resizer/parent'
import { NcActionButton, NcActions } from '@nextcloud/vue'
import IconDomain from 'vue-material-design-icons/Domain.vue'
import IconMail from 'vue-material-design-icons/EmailOutline.vue'
import IconImage from 'vue-material-design-icons/ImageSizeSelectActual.vue'
import BlockedContentWarning from './BlockedContentWarning.vue'
import MdnRequest from './MdnRequest.vue'
import NeedsTranslationInfo from './NeedsTranslationInfo.vue'
import logger from '../logger.js'
Expand All @@ -68,13 +47,9 @@ import { isPrintShortcut } from '../util/printMessage.ts'
export default {
name: 'MessageHTMLBody',
components: {
BlockedContentWarning,
MdnRequest,
NeedsTranslationInfo,
NcActions,
NcActionButton,
IconImage,
IconMail,
IconDomain,
},

props: {
Expand Down Expand Up @@ -208,19 +183,19 @@ export default {
// account for 12px (was 8) margin on iframe body
// should be 12px so it matches the rest of the content
.html-message-body {
display: flex;
flex-direction: column;
gap: calc(var(--default-grid-baseline) * 2);
margin : 2px calc(var(--default-grid-baseline) * 3) 0 calc(var(--default-grid-baseline) * 14);
background-color: #FFFFFF;
border-radius: var(--border-radius-element);

@media (max-width: 600px) {
margin-inline: calc(var(--default-grid-baseline) * 3);
}
}

#mail-message-has-blocked-content {
margin-inline-start: 10px;
color: var(--color-text-maxcontrast) !important;
padding-top: 5px;
&__content {
background-color: #FFFFFF;
border-radius: var(--border-radius-element);
}
}

#message-container {
Expand All @@ -237,24 +212,8 @@ export default {
}
}

:deep(.button-vue__text) {
border: none !important;
font-weight: normal !important;
padding-inline: 14px 10px !important;
text-decoration: underline !important;
}

.message-frame {
width: 100%;
border-radius: var(--border-radius-element);
}

:deep(.button-vue__icon) {
display: none !important;
}

:deep(.button-vue--vue-tertiary) {
color: var(--color-text-maxcontrast);
}

</style>
73 changes: 73 additions & 0 deletions src/tests/unit/components/MessageHTMLBody.vue.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@
*/

import { createLocalVue, shallowMount } from '@vue/test-utils'
import BlockedContentWarning from '../../../components/BlockedContentWarning.vue'
import MessageHTMLBody from '../../../components/MessageHTMLBody.vue'
import Nextcloud from '../../../mixins/Nextcloud.js'
import { trustSender } from '../../../service/TrustedSenderService.js'

vi.mock('@iframe-resizer/parent', () => ({ default: vi.fn() }))
vi.mock('@nextcloud/initial-state', () => ({ loadState: vi.fn().mockReturnValue(false) }))
Expand Down Expand Up @@ -39,6 +41,77 @@ describe('MessageHTMLBody', () => {
...modifiers,
})

// The frame starts out empty here, because jsdom does not fetch its source
const loadFrameWithBlockedImage = (view) => {
const doc = view.vm.getIframeDoc()
doc.write('<img data-original-src="https://example.com/tracker.gif">')
doc.close()
view.vm.onMessageFrameLoad()
}

describe('blocked content', () => {
beforeEach(() => {
trustSender.mockClear()
})

it('warns about images the message would have loaded', async () => {
const view = mountBody()

loadFrameWithBlockedImage(view)
await view.vm.$nextTick()

expect(view.findComponent(BlockedContentWarning).exists()).toBe(true)
})

it('keeps the warning out of the message body, where it would sit on the message\'s own background', async () => {
const view = mountBody()

loadFrameWithBlockedImage(view)
await view.vm.$nextTick()

const body = view.find('.html-message-body__content')
expect(body.findComponent(BlockedContentWarning).exists()).toBe(false)
})

it('reveals the images for this message only', async () => {
const view = mountBody()
loadFrameWithBlockedImage(view)
await view.vm.$nextTick()

view.findComponent(BlockedContentWarning).vm.$emit('show')
await view.vm.$nextTick()

expect(view.vm.getIframeDoc().querySelector('img').getAttribute('src'))
.toBe('https://example.com/tracker.gif')
expect(trustSender).not.toHaveBeenCalled()
expect(view.findComponent(BlockedContentWarning).exists()).toBe(false)
})

it('remembers a sender the reader trusts', async () => {
const view = mountBody()
loadFrameWithBlockedImage(view)
await view.vm.$nextTick()

view.findComponent(BlockedContentWarning).vm.$emit('trust-sender')
await view.vm.$nextTick()

expect(trustSender).toHaveBeenCalledWith('alice@example.com', 'individual', true)
expect(view.findComponent(BlockedContentWarning).exists()).toBe(false)
})

it('remembers a domain the reader trusts', async () => {
const view = mountBody()
loadFrameWithBlockedImage(view)
await view.vm.$nextTick()

view.findComponent(BlockedContentWarning).vm.$emit('trust-domain')
await view.vm.$nextTick()

expect(trustSender).toHaveBeenCalledWith('example.com', 'domain', true)
expect(view.findComponent(BlockedContentWarning).exists()).toBe(false)
})
})

describe('iframe sandbox', () => {
it('allows scripts so the injected resizer can size the frame', () => {
const view = mountBody()
Expand Down
Loading