}
+ */
+ 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 `
-