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
15 changes: 12 additions & 3 deletions lib/Model/IMAPMessage.php
Original file line number Diff line number Diff line change
Expand Up @@ -248,9 +248,21 @@ public function getSentDate(): Horde_Imap_Client_DateTime {
public function getFullMessage(int $id, bool $loadBody = true): array {
$mailBody = $this->plainMessage;
$data = $this->jsonSerialize();
$hasPlainBody = trim($mailBody) !== '';
$data['hasPlainBody'] = $hasPlainBody;
$data['signature'] = null;

if ($hasPlainBody) {
$mailBody = $this->htmlService->convertLinks($mailBody);
[$mailBody, $signature] = $this->htmlService->parseMailBody($mailBody);
$data['signature'] = $signature;
}

if ($this->hasHtmlMessage && $loadBody) {
$data['body'] = $this->getHtmlBody($id);
if ($hasPlainBody) {
$data['plainBody'] = $mailBody;
}
}

if ($this->hasHtmlMessage) {
Expand All @@ -260,9 +272,6 @@ public function getFullMessage(int $id, bool $loadBody = true): array {
return $data;
}

$mailBody = $this->htmlService->convertLinks($mailBody);
[$mailBody, $signature] = $this->htmlService->parseMailBody($mailBody);
$data['signature'] = $signature;
$data['attachments'] = array_merge($this->attachments, $this->inlineAttachments);
$data['inlineAttachments'] = [];
if ($loadBody) {
Expand Down
4 changes: 3 additions & 1 deletion lib/ResponseDefinitions.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@
* dateInt: int<0, max>,
* flags: array{seen: bool, flagged: bool, answered: bool, deleted: bool, draft: bool, forwarded: bool, hasAttachments: bool, mdnsent: bool, important: bool},
* hasHtmlBody?: bool,
* hasPlainBody: bool,
* body?: string,
* plainBody?: string,
* signature: ?string,
* dispositionNotificationTo: string,
* hasDkimSignature: bool,
* phishingDetails: array{checks: list<array{type: string, isPhishing: bool, message: string, additionalData: array<string, mixed>}>, warning: bool},
Expand All @@ -35,7 +38,6 @@
* }
*
* @psalm-type MailMessageApiResponse = MailIMAPFullMessage&array{
* signature: ?string,
* itineraries?: array<string, mixed>,
* id: int<1, max>,
* isSenderTrusted: bool,
Expand Down
21 changes: 21 additions & 0 deletions src/components/MenuEnvelope.vue
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,15 @@
</template>
{{ t('mail', 'Create task') }}
</NcActionButton>
<NcActionButton
v-if="hasPlainBody"
:close-after-click="true"
@click.prevent="$emit('toggle-textual-version')">
<template #icon>
<TextBoxOutlineIcon :size="20" />
</template>
{{ showingTextualVersion ? t('mail', 'View HTML version') : t('mail', 'View textual version') }}
</NcActionButton>
<NcActionButton
v-if="withShowSource"
:close-after-click="true"
Expand Down Expand Up @@ -325,6 +334,7 @@ import PlusIcon from 'vue-material-design-icons/Plus.vue'
import PrinterIcon from 'vue-material-design-icons/PrinterOutline.vue'
import ShareIcon from 'vue-material-design-icons/ShareOutline.vue'
import TagIcon from 'vue-material-design-icons/TagOutline.vue'
import TextBoxOutlineIcon from 'vue-material-design-icons/TextBoxOutline.vue'
import TranslationIcon from 'vue-material-design-icons/Translate.vue'
import DownloadIcon from 'vue-material-design-icons/TrayArrowDown.vue'
import logger from '../logger.js'
Expand Down Expand Up @@ -356,6 +366,7 @@ export default {
PlusIcon,
ShareIcon,
TagIcon,
TextBoxOutlineIcon,
ImportantIcon,
ImportantOutlineIcon,
TaskIcon,
Expand Down Expand Up @@ -395,6 +406,16 @@ export default {
default: true,
},

hasPlainBody: {
type: Boolean,
default: false,
},

showingTextualVersion: {
type: Boolean,
default: false,
},

isTranslationAvailable: {
type: Boolean,
required: false,
Expand Down
19 changes: 17 additions & 2 deletions src/components/Message.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

<template>
<div
:class="[message.hasHtmlBody ? 'mail-message-body mail-message-body-html' : 'mail-message-body']"
:class="[showHtmlBody ? 'mail-message-body mail-message-body-html' : 'mail-message-body']"
role="region"
:aria-label="t('mail', 'Message body')">
<PhishingWarning v-if="message.phishingDetails.warning" :phishing-data="message.phishingDetails.checks" />
Expand All @@ -30,13 +30,19 @@
:scheduling="scheduling" />
</div>
<MessageHTMLBody
v-if="message.hasHtmlBody"
v-if="showHtmlBody"
:url="htmlUrl"
:message="message"
:full-height="fullHeight"
@load="$emit('load', $event)"
@print-shortcut="$emit('print-shortcut')"
@translate="$emit('translate', $event)" />
<MessagePlainTextBody
v-else-if="showTextualVersion"
:body="message.plainBody"
:signature="message.signature"
:message="message"
@translate="$emit('translate', $event)" />
<MessageEncryptedBody
v-else-if="isEncrypted || isPgpMimeEncrypted"
:body="message.body"
Expand Down Expand Up @@ -152,6 +158,11 @@ export default {
required: true,
type: String,
},

showTextualVersion: {
type: Boolean,
default: false,
},
},

data() {
Expand All @@ -166,6 +177,10 @@ export default {
return this.message.from.length === 0 ? '?' : this.message.from[0].label || this.message.from[0].email
},

showHtmlBody() {
return this.message.hasHtmlBody && !this.showTextualVersion
},

htmlUrl() {
return generateUrl('/apps/mail/api/messages/{id}/html', {
id: this.envelope.databaseId,
Expand Down
13 changes: 13 additions & 0 deletions src/components/ThreadEnvelope.vue
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,8 @@
:mailbox="mailbox"
:with-select="false"
:with-show-source="true"
:has-plain-body="message?.hasHtmlBody === true && message?.hasPlainBody === true"
:showing-textual-version="showingTextualVersion"
:more-actions-open.sync="moreActionsOpen"
@reply="onReply('', false, false)"
@delete="$emit('delete', envelope.databaseId)"
Expand All @@ -255,6 +257,7 @@
@open-event-modal="onOpenEventModal"
@open-task-modal="onOpenTaskModal"
@open-translation-modal="onOpenTranslationModal"
@toggle-textual-version="onToggleTextualVersion"
@open-mail-filter-from-envelope="showMailFilterFromEnvelope = true"
@print="onPrint" />
</NcActions>
Expand Down Expand Up @@ -345,6 +348,7 @@
v-show="loading === Loading.Done"
:envelope="envelope"
:message="message"
:show-textual-version="showingTextualVersion"
:full-height="fullHeight"
:smart-replies="showFollowUpHeader ? [] : smartReplies"
:reply-button-label="replyButtonLabel"
Expand Down Expand Up @@ -562,6 +566,7 @@ export default {
enabledFreePrompt: loadState('mail', 'llm_freeprompt_available', false),
loadingBodyTimeout: undefined,
showMailFilterFromEnvelope: false,
showingTextualVersion: false,
}
},

Expand Down Expand Up @@ -795,6 +800,7 @@ export default {
this.message = undefined
this.loading = Loading.Done
this.showRecipients = false
this.showingTextualVersion = false
}
},

Expand Down Expand Up @@ -856,6 +862,13 @@ export default {
this.loading = Loading.Done
},

onToggleTextualVersion() {
this.showingTextualVersion = !this.showingTextualVersion
if (this.showingTextualVersion) {
this.onMessageLoaded()
}
},

async fetchMessage() {
let loadingTimeout
const isCached = !!this.mainStore.getMessage(this.envelope.databaseId)
Expand Down
69 changes: 69 additions & 0 deletions src/tests/unit/components/MenuEnvelope.vue.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { createLocalVue, shallowMount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import MenuEnvelope from '../../../components/MenuEnvelope.vue'
import Nextcloud from '../../../mixins/Nextcloud.js'

const localVue = createLocalVue()
localVue.mixin(Nextcloud)

describe('MenuEnvelope', () => {
beforeEach(() => {
setActivePinia(createPinia())
})

const mountMenu = (propsData = {}) => shallowMount(MenuEnvelope, {
localVue,
propsData: {
envelope: {
databaseId: 123,
accountId: 1,
flags: { flagged: false, seen: true },
subject: 'Subject',
},
mailbox: { accountId: 1 },
...propsData,
},
computed: {
account: () => ({ snoozeMailboxId: null }),
hasWriteAcl: () => false,
hasDeleteAcl: () => false,
tasksEnabled: () => false,
isSnoozeDisabled: () => true,
isSnoozedMailbox: () => false,
isTranslationEnabled: () => false,
isSieveEnabled: () => false,
},
data: () => ({ localMoreActionsOpen: true }),
})

it('does not offer the textual version without a plain body', () => {
const view = mountMenu()

expect(view.text()).not.toContain('View textual version')
})

it('offers the textual version and emits the toggle event', async () => {
const view = mountMenu({ hasPlainBody: true })
const action = view.findAllComponents({ name: 'NcActionButton' })
.wrappers.find((button) => button.text().includes('View textual version'))

action.vm.$emit('click', { preventDefault: vi.fn() })
await view.vm.$nextTick()

expect(view.emitted('toggle-textual-version')).toHaveLength(1)
})

it('offers the HTML version while showing text', () => {
const view = mountMenu({
hasPlainBody: true,
showingTextualVersion: true,
})

expect(view.text()).toContain('View HTML version')
})
})
63 changes: 63 additions & 0 deletions src/tests/unit/components/Message.vue.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { createLocalVue, shallowMount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import Message from '../../../components/Message.vue'
import MessageHTMLBody from '../../../components/MessageHTMLBody.vue'
import MessagePlainTextBody from '../../../components/MessagePlainTextBody.vue'
import Nextcloud from '../../../mixins/Nextcloud.js'

vi.mock('@nextcloud/router', () => ({
generateUrl: vi.fn().mockReturnValue('/message/html'),
}))

const localVue = createLocalVue()
localVue.mixin(Nextcloud)

describe('Message', () => {
beforeEach(() => {
setActivePinia(createPinia())
})

const mountMessage = (showTextualVersion = false) => shallowMount(Message, {
localVue,
propsData: {
envelope: { databaseId: 123 },
message: {
hasHtmlBody: true,
body: '<p>HTML body</p>',
plainBody: 'Plain body',
signature: 'Signature',
phishingDetails: { warning: false, checks: [] },
smime: { isSigned: false },
scheduling: [],
attachments: [],
from: [],
isPgpMimeEncrypted: false,
},
replyButtonLabel: 'Reply',
showTextualVersion,
},
})

it('shows the HTML body by default', () => {
const view = mountMessage()

expect(view.findComponent(MessageHTMLBody).exists()).toBe(true)
expect(view.findComponent(MessagePlainTextBody).exists()).toBe(false)
expect(view.classes()).toContain('mail-message-body-html')
})

it('shows the MIME plain body when requested', () => {
const view = mountMessage(true)

const plainBody = view.findComponent(MessagePlainTextBody)
expect(view.findComponent(MessageHTMLBody).exists()).toBe(false)
expect(plainBody.props('body')).toBe('Plain body')
expect(plainBody.props('signature')).toBe('Signature')
expect(view.classes()).not.toContain('mail-message-body-html')
})
})
36 changes: 36 additions & 0 deletions src/tests/unit/components/ThreadEnvelope.vue.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -429,4 +429,40 @@ describe('ThreadEnvelope', () => {

expect(view.vm.hasWriteAcl).toBe(true)
})

it('finishes HTML loading when switching to the textual version', async () => {
const view = shallowMount(ThreadEnvelope, {
propsData: {
envelope: {
accountId: 123,
from: [{ email: 'info@test.com' }],
flags: { seen: true, flagged: false, $junk: false, answered: false, hasAttachments: false, draft: false },
subject: '',
dateInt: 1692200926180,
},
threadSubject: '',
},
computed: {
mailbox() {
return { myAcls: undefined }
},
},
localVue,
})
const loadingBodyTimeout = setTimeout(() => {}, 1000)
await view.setData({
loading: view.vm.Loading.Skeleton,
loadingBodyTimeout,
})

view.vm.onToggleTextualVersion()

expect(view.vm.showingTextualVersion).toBe(true)
expect(view.vm.loading).toBe(view.vm.Loading.Done)
expect(view.vm.loadingBodyTimeout).toBeUndefined()

view.vm.onToggleTextualVersion()

expect(view.vm.showingTextualVersion).toBe(false)
})
})
Loading