From 23b0fd0a70d91e3264ee47235d9fbe93c55e4c32 Mon Sep 17 00:00:00 2001 From: Grigory Vodyanov Date: Fri, 3 Jul 2026 18:24:27 +0200 Subject: [PATCH] fix: unify mail printing Signed-off-by: Grigory Vodyanov --- src/components/Thread.vue | 354 +++++++++-------------- src/tests/unit/util/printMessage.spec.js | 180 ++++++++++++ src/util/printMessage.ts | 160 ++++++++++ src/views/Home.vue | 21 -- 4 files changed, 483 insertions(+), 232 deletions(-) create mode 100644 src/tests/unit/util/printMessage.spec.js create mode 100644 src/util/printMessage.ts diff --git a/src/components/Thread.vue b/src/components/Thread.vue index 3582f0cc84..187eb8300e 100644 --- a/src/components/Thread.vue +++ b/src/components/Thread.vue @@ -21,6 +21,7 @@ [ - ...(envelope.from ?? []), - ...(envelope.to ?? []), - ]).filter(({ email }) => { - if (seen.has(email)) { - return false - } - seen.add(email) - return true - }) - }, - showSummaryBox() { return this.thread.length > 2 && this.enabledThreadSummary && !this.summaryError }, @@ -278,223 +266,136 @@ export default { }, async handleKeyDown(event) { - if ((event.ctrlKey || event.metaKey) && event.key === 'p') { - event.preventDefault() - - try { - this.thread.forEach((thread) => { - if (!this.expandedThreads.includes(thread.databaseId)) { - this.expandedThreads.push(thread.databaseId) - } - }) - - while (true) { - if (this.loadedThreads === this.thread.length) { - break - } - await new Promise((resolve) => setTimeout(resolve, 100)) - } + if (!(event.ctrlKey || event.metaKey) || event.key !== 'p') { + return + } + event.preventDefault() - const virtualIframe = document.createElement('iframe') - virtualIframe.style.position = 'absolute' - document.body.appendChild(virtualIframe) - const virtualIframeDocument = virtualIframe.contentDocument || virtualIframe.contentWindow.document - virtualIframeDocument.open() - virtualIframeDocument.write(`${t('mail', 'Print')}`) - virtualIframeDocument.close() - - virtualIframeDocument.body.appendChild(this.addThreadInfo(virtualIframeDocument)) - - const messageContainers = document.querySelectorAll('#message-container') - for (const [index, messageContainer] of messageContainers.entries()) { - const iframe = messageContainer.querySelector('iframe') - - this.addMessageInfo(virtualIframeDocument, index) - - if (!iframe) { - const div = virtualIframeDocument.createElement('div') - div.innerHTML = messageContainer.innerHTML - virtualIframeDocument.body.appendChild(div) - continue - } - - if (iframe.contentWindow.document.readyState !== 'complete') { - await new Promise((resolve) => { - iframe.contentWindow.onload = resolve - }) - } - - const iframeDocument = iframe.contentDocument || iframe.contentWindow.document - const iframeContent = iframeDocument.body.innerHTML - const div = virtualIframeDocument.createElement('div') - - div.innerHTML = iframeContent - virtualIframeDocument.body.appendChild(div) + try { + this.thread.forEach((envelope) => { + if (!this.expandedThreads.includes(envelope.databaseId)) { + this.expandedThreads.push(envelope.databaseId) } + }) - const images = virtualIframeDocument.querySelectorAll('img') - let imagesLoaded = 0 - - images.forEach((img) => { - img.addEventListener('load', () => { - imagesLoaded++ - if (imagesLoaded === images.length) { - virtualIframe.contentWindow.print() - this.removeIframe(virtualIframe) - } - }) - img.addEventListener('error', () => { - imagesLoaded++ - if (imagesLoaded === images.length) { - virtualIframe.contentWindow.print() - this.removeIframe(virtualIframe) - } - }) - }) - - if (images.length === 0) { - virtualIframe.contentWindow.print() - this.removeIframe(virtualIframe) - } - } catch (error) { - logger.error('Could not print message', { error }) - showError(t('mail', 'Could not print message')) + while (this.loadedThreads < this.thread.length) { + await wait(100) } + + const indices = this.thread + .map((envelope, index) => index) + .filter((index) => this.expandedThreads.includes(this.thread[index].databaseId)) + await this.printMessages(indices) + } catch (error) { + logger.error('Could not print message', { error }) + showError(t('mail', 'Could not print message')) } }, - removeIframe(virtualIframe) { - setTimeout(() => { - document.body.removeChild(virtualIframe) - }, 500) + addLoadedThread() { + this.loadedThreads++ }, - addMessageInfo(virtualIframeDocument, index) { - const hr = virtualIframeDocument.createElement('hr') - hr.style.border = '1px solid black' - - const subjectSpan = virtualIframeDocument.createElement('p') - subjectSpan.style.fontWeight = 'bold' - subjectSpan.textContent = t('mail', 'Subject') + ': ' + this.thread[index].subject - - const senderSpan = virtualIframeDocument.createElement('p') - senderSpan.style.fontWeight = 'bold' - senderSpan.textContent = t('mail', 'From') + ': ' + this.thread[index].from[0].label + ' <' + this.thread[index].from[0].email + '>' - - const dateSpan = virtualIframeDocument.createElement('p') - dateSpan.style.fontWeight = 'bold' - dateSpan.textContent = t('mail', 'Date') + ': ' + formatDateTimeFromUnix(this.thread[index].dateInt) - - const recipientSpan = virtualIframeDocument.createElement('p') - recipientSpan.style.fontWeight = 'bold' - recipientSpan.textContent = t('mail', 'To') + ': ' + this.thread[index].to[0].label + this.thread[index].to[0].email - - virtualIframeDocument.body.appendChild(hr) - virtualIframeDocument.body.appendChild(subjectSpan) - virtualIframeDocument.body.appendChild(senderSpan) - virtualIframeDocument.body.appendChild(dateSpan) - virtualIframeDocument.body.appendChild(recipientSpan) + async print(threadIndex) { + try { + await this.printMessages([threadIndex ?? this.thread.length - 1]) + } catch (error) { + logger.error('Could not print message', { error }) + showError(t('mail', 'Could not print message')) + } }, - addThreadInfo(document) { - const threadInfo = document.createElement('div') - threadInfo.style.marginTop = '20px' - threadInfo.style.marginBottom = '20px' - threadInfo.className = 'mail-thread-info' - - const subjectLine = document.createElement('h2') - subjectLine.textContent = `${this.threadSubject}` - threadInfo.appendChild(subjectLine) - - const participantsLine = document.createElement('p') - participantsLine.textContent = this.threadParticipants - .map((participant) => `${participant.label} <${participant.email}>`) - .join(', ') - threadInfo.appendChild(participantsLine) - - return threadInfo - }, + /** + * Print the given thread messages by rendering them into a dedicated, + * hidden iframe and printing that iframe's own document. + * + * This is deliberately isolated from the main document: the messages + * carry their own (untrusted) CSS, which would otherwise leak into + * the app and could shrink or clip the whole page. Because the iframe + * document is printed as a standalone page, long emails also paginate + * across pages instead of being cut off, and the app layout is never + * mutated — so nothing needs to be reloaded afterwards. + * + * @param {number[]} indices thread indices to print, in order + * @return {Promise} + */ + async printMessages(indices) { + const frame = document.createElement('iframe') + frame.setAttribute('aria-hidden', 'true') + // The message HTML is copied into this iframe from the sanitized + // message frame, but unlike that frame it is not protected by the + // backend's Content-Security-Policy. Sandbox it without + // `allow-scripts` so no script or inline event handler in the + // content can run; `allow-same-origin` lets us populate it and + // `allow-modals` lets it open the print dialog. + frame.setAttribute('sandbox', 'allow-same-origin allow-modals') + frame.style.cssText = 'position: fixed; left: -9999px; top: 0; width: 0; height: 0; border: 0;' + document.body.appendChild(frame) + + let cleanedUp = false + const cleanup = () => { + if (cleanedUp) { + return + } + cleanedUp = true + frame.remove() + } - addLoadedThread() { - this.loadedThreads++ - }, + try { + const doc = frame.contentDocument || frame.contentWindow.document + doc.open() + doc.write('') + doc.close() + doc.title = this.threadSubject - print(threadIndex) { - setTimeout(() => { - try { - const messages = Array.from(document.querySelectorAll('.html-message-body, .mail-message-body')) + const style = doc.createElement('style') + style.textContent = PRINT_DOCUMENT_STYLE + doc.head.appendChild(style) - let message + indices.forEach((index) => this.appendMessageToDocument(doc, index)) - if (threadIndex !== undefined) { - message = messages[threadIndex * 2] ?? messages.pop() - } else { - // By default, we print the last opened message in the thread - message = messages.pop() - } + await waitForImages(doc) - const iframe = message.querySelector('iframe') - - if (iframe === null) { - // Handle plain text messages - const messageContainer = message.querySelector('#message-container') - - if (messageContainer) { - // Create a new iframe - const newIframe = document.createElement('iframe') - newIframe.style.display = 'none' // Hide the iframe - document.body.appendChild(newIframe) - - // Insert the message content into the iframe - const iframeDocument = newIframe.contentDocument || newIframe.contentWindow.document - iframeDocument.open() - iframeDocument.write(` - - - - - -
${messageContainer.innerHTML}
- - - `) - iframeDocument.title = this.threadSubject - - const threadInfo = this.addThreadInfo(iframeDocument) - iframeDocument.body.insertBefore(threadInfo, iframeDocument.body.firstChild) - - setTimeout(() => { - threadInfo.remove() - }, 5000) - - iframeDocument.close() - - newIframe.contentWindow.print() - - // Clean up: remove the iframe after printing - setTimeout(() => { - document.body.removeChild(newIframe) - }, 500) - } - - return - } + frame.contentWindow.addEventListener('afterprint', cleanup, { once: true }) + frame.contentWindow.focus() + frame.contentWindow.print() - const iframeDocument = iframe.contentDocument || iframe.contentWindow.document + // Safety net: some browsers don't emit `afterprint`. The delay + // is long enough not to abort an open print dialog. + setTimeout(cleanup, 60000) + } catch (error) { + cleanup() + throw error + } + }, - const threadInfo = this.addThreadInfo(iframeDocument) - iframeDocument.body.insertBefore(threadInfo, iframeDocument.body.firstChild) + appendMessageToDocument(doc, index) { + const envelope = this.thread[index] + const envelopeComponent = this.$refs.envelopeRefs?.[index] + if (!envelope || !envelopeComponent) { + return + } - setTimeout(() => { - threadInfo.remove() - }, 200) + const messageEl = envelopeComponent.$el + const message = doc.createElement('div') + message.className = 'print-message' + message.appendChild(buildMessageHeader(doc, envelope)) - iframe.contentWindow.print() - } catch (error) { - logger.error('Could not print message', { error }) - showError(t('mail', 'Could not print message')) + const iframe = messageEl.querySelector('iframe') + if (iframe) { + const sourceDocument = iframe.contentDocument || iframe.contentWindow.document + message.appendChild(buildHtmlMessageContent(doc, sourceDocument)) + } else { + const messageContainer = messageEl.querySelector('#message-container') + if (messageContainer) { + const content = doc.createElement('div') + content.className = 'print-message-content' + content.innerHTML = messageContainer.innerHTML + message.appendChild(content) } - }, 100) + } + + doc.body.appendChild(message) }, }, } @@ -673,4 +574,35 @@ export default { .user-bubble__title { cursor: pointer; } + +// The "Print message" action and Ctrl/Cmd+P render into an isolated iframe +// and print that, so these rules only matter for a native browser print +// (browser menu / right-click → Print) of the live page. They hide the app +// chrome and let the document flow so the message body isn't printed inside +// the surrounding UI. Because the message body keeps its own sandboxed +// iframe, none of its CSS leaks into the app, and these rules revert +// automatically once printing is done — nothing needs to be reloaded. +@media print { + html, + body { + height: auto !important; + min-height: 0 !important; + overflow: visible !important; + position: static !important; + } + + .app-navigation, + .app-content-list, + .message-composer, + .reply-buttons, + #reply-composer, + #mail-message-has-blocked-content, + .mail-message-attachments { + display: none !important; + } + + #mail-thread-header { + position: static !important; + } +} diff --git a/src/tests/unit/util/printMessage.spec.js b/src/tests/unit/util/printMessage.spec.js new file mode 100644 index 0000000000..09cf38c041 --- /dev/null +++ b/src/tests/unit/util/printMessage.spec.js @@ -0,0 +1,180 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { formatDateTimeFromUnix } from '../../../util/formatDateTime.js' +import { buildHtmlMessageContent, buildMessageHeader, waitForImages } from '../../../util/printMessage.ts' + +describe('printMessage', () => { + describe('buildMessageHeader', () => { + const envelope = { + subject: 'Hello there', + from: [{ label: 'Alice', email: 'alice@example.com' }], + to: [{ label: 'Bob', email: 'bob@example.com' }], + cc: [], + bcc: [], + dateInt: 1700000000, + } + + it('renders the subject first', () => { + const header = buildMessageHeader(document, envelope) + + expect(header.firstChild.textContent).toBe('Hello there') + }) + + it('falls back to "No subject" when the subject is empty', () => { + const header = buildMessageHeader(document, { ...envelope, subject: '' }) + + expect(header.firstChild.textContent).toBe('No subject') + }) + + it('gives the subject an explicit inline font size so email CSS cannot enlarge it', () => { + const header = buildMessageHeader(document, envelope) + + expect(header.firstChild.style.fontSize).not.toBe('') + }) + + it('lists From, To and Date in that order', () => { + const header = buildMessageHeader(document, envelope) + + const lines = Array.from(header.querySelectorAll('.print-message-header > div')) + .slice(1) + .map((line) => line.textContent) + expect(lines).toEqual([ + 'From: Alice ', + 'To: Bob ', + `Date: ${formatDateTimeFromUnix(envelope.dateInt)}`, + ]) + }) + + it('omits Cc and Bcc lines when there are no such recipients', () => { + const header = buildMessageHeader(document, envelope) + + const lines = Array.from(header.querySelectorAll('div')).map((line) => line.textContent) + expect(lines.some((line) => line.startsWith('Cc:'))).toBe(false) + expect(lines.some((line) => line.startsWith('Bcc:'))).toBe(false) + }) + + it('includes Cc and Bcc lines when present, with all recipients', () => { + const header = buildMessageHeader(document, { + ...envelope, + cc: [{ label: 'Carol', email: 'carol@example.com' }], + bcc: [{ label: 'Dave', email: 'dave@example.com' }, { label: '', email: 'eve@example.com' }], + }) + + const lines = Array.from(header.querySelectorAll('div')).map((line) => line.textContent) + expect(lines).toContain('Cc: Carol ') + expect(lines).toContain('Bcc: Dave , eve@example.com') + }) + + it('omits the To line when there are no recipients', () => { + const header = buildMessageHeader(document, { ...envelope, to: [] }) + + const lines = Array.from(header.querySelectorAll('div')).map((line) => line.textContent) + expect(lines.some((line) => line.startsWith('To:'))).toBe(false) + }) + + it('renders a crafted subject as literal text, never as markup', () => { + const header = buildMessageHeader(document, { + ...envelope, + subject: '', + }) + + expect(header.firstChild.textContent).toBe('') + expect(header.querySelector('img')).toBeNull() + }) + + it('renders a crafted sender name as literal text, never as markup', () => { + const header = buildMessageHeader(document, { + ...envelope, + from: [{ label: '', email: 'x@example.com' }], + }) + + expect(header.querySelector('script')).toBeNull() + expect(header.textContent).toContain(' ') + }) + }) + + describe('buildHtmlMessageContent', () => { + it('keeps the message body and its styles so it does not fall back to unstyled defaults', () => { + const sourceDocument = new DOMParser().parseFromString( + '

hello

', + 'text/html', + ) + + const wrapper = buildHtmlMessageContent(document, sourceDocument) + + expect(wrapper.querySelector('style').textContent).toContain('color: red') + expect(wrapper.querySelector('p').textContent).toBe('hello') + }) + + it('keeps a style block that the backend injected at the start of the body', () => { + const sourceDocument = new DOMParser().parseFromString( + '

hi

', + 'text/html', + ) + + const wrapper = buildHtmlMessageContent(document, sourceDocument) + + expect(wrapper.textContent).toContain('hi') + expect(wrapper.querySelector('style').textContent).toContain('color: green') + }) + + it('strips scripts and the iframe-resizer marker from the printed content', () => { + const sourceDocument = new DOMParser().parseFromString( + '

hi

', + 'text/html', + ) + + const wrapper = buildHtmlMessageContent(document, sourceDocument) + + expect(wrapper.querySelector('script')).toBeNull() + expect(wrapper.querySelector('[data-iframe-size]')).toBeNull() + expect(wrapper.querySelector('p').textContent).toBe('hi') + }) + }) + + describe('waitForImages', () => { + it('resolves immediately when there are no images', async () => { + const container = document.createElement('div') + + await expect(waitForImages(container)).resolves.toBeUndefined() + }) + + it('resolves immediately when every image is already complete', async () => { + const container = document.createElement('div') + const img = document.createElement('img') + Object.defineProperty(img, 'complete', { value: true }) + container.appendChild(img) + + await expect(waitForImages(container)).resolves.toBeUndefined() + }) + + it('waits for pending images to load or error before resolving', async () => { + const container = document.createElement('div') + const loadingImg = document.createElement('img') + Object.defineProperty(loadingImg, 'complete', { value: false }) + const erroringImg = document.createElement('img') + Object.defineProperty(erroringImg, 'complete', { value: false }) + container.appendChild(loadingImg) + container.appendChild(erroringImg) + + let resolved = false + waitForImages(container).then(() => { + resolved = true + }) + + await Promise.resolve() + expect(resolved).toBe(false) + + loadingImg.dispatchEvent(new Event('load')) + await Promise.resolve() + expect(resolved).toBe(false) + + erroringImg.dispatchEvent(new Event('error')) + await new Promise((resolve) => setTimeout(resolve)) + expect(resolved).toBe(true) + }) + }) +}) diff --git a/src/util/printMessage.ts b/src/util/printMessage.ts new file mode 100644 index 0000000000..ed547ab3c4 --- /dev/null +++ b/src/util/printMessage.ts @@ -0,0 +1,160 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { translate as t } from '@nextcloud/l10n' +import { formatDateTimeFromUnix } from './formatDateTime.js' + +export interface PrintRecipient { + email: string + label?: string +} + +export interface PrintEnvelope { + bcc?: PrintRecipient[] + cc?: PrintRecipient[] + dateInt?: number + from?: PrintRecipient[] + subject?: string + to?: PrintRecipient[] +} + +/** + * Base stylesheet for the standalone print document. Kept intentionally + * small: the message bodies bring their own (isolated) CSS, this only lays + * out the page margins, the spacing between messages of a thread, and keeps + * a message header attached to the start of its content. + */ +export const PRINT_DOCUMENT_STYLE = ` + @page { margin: 15mm; } + html { color: #000; } + body { margin: 0; font-family: sans-serif; color: #000; } + .print-message + .print-message { margin-top: 24px; } + .print-message-header { break-inside: avoid; break-after: avoid; } +` + +function formatRecipients(recipients?: PrintRecipient[]): string { + return (recipients ?? []) + .map(({ label, email }) => (label ? `${label} <${email}>` : email)) + .join(', ') +} + +/** + * Build a single, self-contained print header for a message: Subject, From, + * To, Cc, Bcc and Date/time. Cc/Bcc lines are omitted when empty so a plain + * message doesn't show blank fields. + * + * All values are inserted via `textContent`/`createTextNode`, never as HTML, + * so a crafted subject or display name (e.g. ``) can only ever + * appear as literal text and cannot inject markup into the print document. + * + * The header is styled with inline `!important` styles on purpose: it lives + * in the same document as the (untrusted) email HTML, whose global CSS would + * otherwise be able to restyle it — that is what caused the oversized "big + * letters" header in the old implementation. Inline `!important` wins even + * over the email's own `!important` rules, so the header always renders + * predictably. + * + * @param doc document to create the header elements in + * @param envelope the envelope/message to build a header for + */ +export function buildMessageHeader(doc: Document, envelope: PrintEnvelope): HTMLElement { + const header = doc.createElement('div') + header.className = 'print-message-header' + header.style.cssText = 'margin: 0 0 16px 0 !important; color: #000 !important; font-family: sans-serif !important;' + + const subject = doc.createElement('div') + subject.style.cssText = 'font-size: 18px !important; font-weight: bold !important; margin: 0 0 8px 0 !important; color: #000 !important; font-family: sans-serif !important;' + subject.textContent = envelope.subject || t('mail', 'No subject') + header.appendChild(subject) + + const addLine = (label: string, value: string): void => { + if (!value) { + return + } + const line = doc.createElement('div') + line.style.cssText = 'font-size: 13px !important; font-weight: normal !important; line-height: 1.4 !important; margin: 0 !important; color: #000 !important; font-family: sans-serif !important;' + const name = doc.createElement('span') + name.style.cssText = 'font-weight: bold !important;' + name.textContent = `${label} ` + line.appendChild(name) + line.appendChild(doc.createTextNode(value)) + header.appendChild(line) + } + + addLine(`${t('mail', 'From')}:`, formatRecipients(envelope.from)) + addLine(t('mail', 'To:'), formatRecipients(envelope.to)) + addLine(t('mail', 'Cc:'), formatRecipients(envelope.cc)) + addLine(t('mail', 'Bcc:'), formatRecipients(envelope.bcc)) + addLine(`${t('mail', 'Date')}:`, envelope.dateInt ? formatDateTimeFromUnix(envelope.dateInt) : '') + + return header +} + +/** + * Extract a rendered HTML message's printable content from its iframe + * document. The message body carries its own ` -