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>
102 changes: 31 additions & 71 deletions src/components/MessageHTMLBody.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,57 +4,36 @@
-->
<template>
<div class="html-message-body">
<MdnRequest :message="message" />
<NeedsTranslationInfo
v-if="needsTranslation"
:is-html="true"
@translate="$emit('translate')" />
<div v-if="hasBlockedContent" id="mail-message-has-blocked-content" style="color: #000000">
{{ t('mail', 'The images have been blocked to protect your privacy.') }}
<Actions type="tertiary" :menu-name="t('mail', 'Show images')">
<ActionButton @click="displayIframe">
<template #icon>
<IconImage :size="20" />
</template>
{{ t('mail', 'Show images temporarily') }}
</ActionButton>
<ActionButton
v-if="sender"
@click="onShowBlockedContent">
<template #icon>
<IconMail :size="20" />
</template>
{{ t('mail', 'Always show images from {sender}', { sender }) }}
</ActionButton>
<ActionButton
v-if="domain"
@click="onShowBlockedContentForDomain">
<template #icon>
<IconDomain :size="20" />
</template>
{{ t('mail', 'Always show images from {domain}', { domain }) }}
</ActionButton>
</Actions>
</div>
<div id="message-container" :class="{ scroll: !fullHeight }">
<iframe
ref="iframe"
class="message-frame"
:title="t('mail', 'Message frame')"
:src="url"
seamless
@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="needsTranslation"
:is-html="true"
@translate="$emit('translate')" />
<div id="message-container" :class="{ scroll: !fullHeight }">
<iframe
ref="iframe"
class="message-frame"
:title="t('mail', 'Message frame')"
:src="url"
seamless
@load="onMessageFrameLoad" />
</div>
</div>
</div>
</template>

<script>
import iframeResize from '@iframe-resizer/parent'
import { loadState } from '@nextcloud/initial-state'
import { NcActionButton as ActionButton, NcActions as Actions } 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 @@ -65,13 +44,9 @@ import { isPrintShortcut } from '../util/printMessage.ts'
export default {
name: 'MessageHTMLBody',
components: {
BlockedContentWarning,
MdnRequest,
NeedsTranslationInfo,
Actions,
ActionButton,
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,23 +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('print shortcut', () => {
it('takes the shortcut from inside the frame, where the app window cannot see it', () => {
const view = mountBody()
Expand Down