diff --git a/lib/Model/IMAPMessage.php b/lib/Model/IMAPMessage.php index f428c4db6e..7d0b89824e 100644 --- a/lib/Model/IMAPMessage.php +++ b/lib/Model/IMAPMessage.php @@ -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) { @@ -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) { diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index 52ef054530..61952fecde 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -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}>, warning: bool}, @@ -35,7 +38,6 @@ * } * * @psalm-type MailMessageApiResponse = MailIMAPFullMessage&array{ - * signature: ?string, * itineraries?: array, * id: int<1, max>, * isSenderTrusted: bool, diff --git a/src/components/MenuEnvelope.vue b/src/components/MenuEnvelope.vue index 885215cd88..add896d82d 100644 --- a/src/components/MenuEnvelope.vue +++ b/src/components/MenuEnvelope.vue @@ -183,6 +183,15 @@ {{ t('mail', 'Create task') }} + + + {{ showingTextualVersion ? t('mail', 'View HTML version') : t('mail', 'View textual version') }} +
@@ -30,13 +30,19 @@ :scheduling="scheduling" />
+ @@ -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" @@ -562,6 +566,7 @@ export default { enabledFreePrompt: loadState('mail', 'llm_freeprompt_available', false), loadingBodyTimeout: undefined, showMailFilterFromEnvelope: false, + showingTextualVersion: false, } }, @@ -795,6 +800,7 @@ export default { this.message = undefined this.loading = Loading.Done this.showRecipients = false + this.showingTextualVersion = false } }, @@ -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) diff --git a/src/tests/unit/components/MenuEnvelope.vue.spec.js b/src/tests/unit/components/MenuEnvelope.vue.spec.js new file mode 100644 index 0000000000..6e36e9c210 --- /dev/null +++ b/src/tests/unit/components/MenuEnvelope.vue.spec.js @@ -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') + }) +}) diff --git a/src/tests/unit/components/Message.vue.spec.js b/src/tests/unit/components/Message.vue.spec.js new file mode 100644 index 0000000000..d3f828ddd7 --- /dev/null +++ b/src/tests/unit/components/Message.vue.spec.js @@ -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: '

HTML body

', + 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') + }) +}) diff --git a/src/tests/unit/components/ThreadEnvelope.vue.spec.js b/src/tests/unit/components/ThreadEnvelope.vue.spec.js index f4bb7cbca5..df49fa352a 100644 --- a/src/tests/unit/components/ThreadEnvelope.vue.spec.js +++ b/src/tests/unit/components/ThreadEnvelope.vue.spec.js @@ -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) + }) }) diff --git a/tests/Unit/Model/IMAPMessageTest.php b/tests/Unit/Model/IMAPMessageTest.php index 2fc87f21ad..87692f72cb 100644 --- a/tests/Unit/Model/IMAPMessageTest.php +++ b/tests/Unit/Model/IMAPMessageTest.php @@ -165,4 +165,102 @@ public function testSerialize() { ], $json); $this->assertEquals(1234, $json['uid']); } + + public function testFullHtmlMessageIncludesPlainAlternative(): void { + $plainBody = "Plain body\n-- \nSignature"; + $processedBody = 'Processed plain body'; + $signature = 'Processed signature'; + $this->htmlService->expects($this->once()) + ->method('convertLinks') + ->with($plainBody) + ->willReturn('Processed message'); + $this->htmlService->expects($this->once()) + ->method('parseMailBody') + ->with('Processed message') + ->willReturn([$processedBody, $signature]); + $this->htmlService->expects($this->once()) + ->method('sanitizeHtmlMailBody') + ->willReturn('

HTML body

'); + $message = $this->createMessage($plainBody, '

HTML body

', true); + + $result = $message->getFullMessage(123); + + $this->assertTrue($result['hasHtmlBody']); + $this->assertTrue($result['hasPlainBody']); + $this->assertSame('

HTML body

', $result['body']); + $this->assertSame($processedBody, $result['plainBody']); + $this->assertSame($signature, $result['signature']); + } + + public function testFullHtmlMessageWithoutPlainAlternative(): void { + $this->htmlService->expects($this->never()) + ->method('convertLinks'); + $this->htmlService->expects($this->never()) + ->method('parseMailBody'); + $this->htmlService->expects($this->once()) + ->method('sanitizeHtmlMailBody') + ->willReturn('

HTML body

'); + $message = $this->createMessage('', '

HTML body

', true); + + $result = $message->getFullMessage(123); + + $this->assertTrue($result['hasHtmlBody']); + $this->assertFalse($result['hasPlainBody']); + $this->assertArrayNotHasKey('plainBody', $result); + $this->assertNull($result['signature']); + } + + public function testFullHtmlMessageDoesNotIncludeBodiesWhenBodyLoadingIsDisabled(): void { + $this->htmlService->expects($this->once()) + ->method('convertLinks') + ->willReturn('Processed message'); + $this->htmlService->expects($this->once()) + ->method('parseMailBody') + ->willReturn(['Processed message', null]); + $this->htmlService->expects($this->never()) + ->method('sanitizeHtmlMailBody'); + $message = $this->createMessage('Plain body', '

HTML body

', true); + + $result = $message->getFullMessage(123, false); + + $this->assertTrue($result['hasPlainBody']); + $this->assertArrayNotHasKey('body', $result); + $this->assertArrayNotHasKey('plainBody', $result); + } + + private function createMessage(string $plainBody, string $htmlBody, bool $hasHtmlBody): IMAPMessage { + return new IMAPMessage( + 1234, + 'foo', + [], + AddressList::parse('from@mail.com'), + AddressList::parse('to@mail.com'), + AddressList::parse('cc@mail.com'), + AddressList::parse('bcc@mail.com'), + AddressList::parse('reply-to@mail.com'), + 'subject', + $plainBody, + $htmlBody, + $hasHtmlBody, + [], + [], + false, + [], + new Horde_Imap_Client_DateTime('2016-01-01 00:00:00'), + '', + '', + false, + false, + [], + null, + false, + null, + '', + false, + false, + false, + $this->htmlService, + false, + ); + } }