fix(email): replace html2pdf with Print / save PDF and harden EML export

SAN-157 removed the html2pdf client library. The PDF export code added
in the original PR commit referenced the deleted library and would fail
at runtime. Replace the html2pdf-based _downloadSelectedEmailPdf with a
browser Print / save PDF workflow using a sandboxed iframe and
window.print(), matching the established pattern in document.js and
compare/index.js.

Changes:
- Replace _loadEmailHtml2Pdf and _downloadSelectedEmailPdf with
  _printSelectedEmails using a sandboxed iframe + contentWindow.print()
- Update menu label from 'PDF' to 'Print / save PDF'
- Add 512 MiB total size cap to /messages-download-bulk to prevent
  unbounded memory growth when exporting many large messages
- Update test assertions to verify the browser print pattern and
  confirm html2pdf is not referenced
- Add regression tests for size cap, selection count validation,
  non-numeric UID rejection, and partial batch failure accounting
This commit is contained in:
Alexandre Teixeira
2026-10-20 10:14:55 +01:00
parent 6cf676dfe2
commit 8eaf742b43
4 changed files with 157 additions and 91 deletions
+10
View File
@@ -4216,6 +4216,10 @@ def setup_email_routes():
used_names: set[str] = set()
exported = 0
skipped = 0
total_bytes = 0
# 512 MiB safety cap prevents unbounded memory growth when exporting
# many large messages.
max_total_bytes = 512 * 1024 * 1024
single_export: tuple[str, bytes] | None = None
def _email_filename(row: dict, uid: str) -> str:
@@ -4299,6 +4303,9 @@ def setup_email_routes():
suffix += 1
used_names.add(unique_name.lower())
message_bytes = _fixture_message_bytes(fixture, uid, folder)
total_bytes += len(message_bytes)
if total_bytes > max_total_bytes:
raise HTTPException(status_code=413, detail="Export too large")
zf.writestr(f"{export_folder}/{unique_name}", message_bytes)
single_export = (unique_name, message_bytes)
exported += 1
@@ -4336,6 +4343,9 @@ def setup_email_routes():
suffix += 1
used_names.add(unique_name.lower())
message_bytes = bytes(raw_message)
total_bytes += len(message_bytes)
if total_bytes > max_total_bytes:
raise HTTPException(status_code=413, detail="Export too large")
zf.writestr(f"{export_folder}/{unique_name}", message_bytes)
single_export = (unique_name, message_bytes)
found.add(uid)
+81 -82
View File
@@ -1122,29 +1122,9 @@ function _filenameFromContentDisposition(header) {
return bare ? bare[1].trim() : '';
}
let _emailHtml2PdfReady = null;
async function _loadEmailHtml2Pdf() {
if (window.html2pdf) return;
if (!_emailHtml2PdfReady) {
_emailHtml2PdfReady = new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = '/static/lib/html2pdf.bundle.min.js';
script.onload = resolve;
script.onerror = () => reject(new Error('Could not load PDF exporter'));
document.head.appendChild(script);
});
}
try {
await _emailHtml2PdfReady;
} catch (error) {
_emailHtml2PdfReady = null;
throw error;
}
}
async function _downloadSelectedEmailPdf(messages) {
if (messages.length > 20) throw new Error('PDF export supports up to 20 emails at a time');
await _loadEmailHtml2Pdf();
async function _printSelectedEmails(messages) {
if (messages.length > 20) throw new Error('Print / save PDF supports up to 20 emails at a time');
if (typeof window.print !== 'function') throw new Error('Browser printing is unavailable');
const loaded = new Array(messages.length);
let nextIndex = 0;
@@ -1163,85 +1143,105 @@ async function _downloadSelectedEmailPdf(messages) {
};
await Promise.all(Array.from({ length: Math.min(4, messages.length) }, readWorker));
const page = document.createElement('div');
page.className = 'email-export-pdf';
page.style.cssText = 'width:794px;box-sizing:border-box;padding:38px;background:#fff;color:#171717;font:12px/1.55 Arial,Helvetica,sans-serif;';
const style = document.createElement('style');
style.textContent = `
.email-export-pdf-message { page-break-after: always; break-after: page; }
.email-export-pdf-message:last-child { page-break-after: auto; break-after: auto; }
.email-export-pdf-message h1 { margin: 0 0 18px; font-size: 20px; line-height: 1.3; overflow-wrap: anywhere; }
.email-export-pdf-fields { margin: 0 0 20px; padding: 0 0 14px; border-bottom: 1px solid #d8d8d8; }
.email-export-pdf-field { display: flex; gap: 10px; margin: 3px 0; overflow-wrap: anywhere; }
.email-export-pdf-label { width: 54px; flex: 0 0 54px; color: #666; }
.email-export-pdf-body { overflow-wrap: anywhere; word-break: break-word; }
.email-export-pdf-body img { max-width: 100%; }
.email-export-pdf-body a { color: #135f9b; text-decoration: underline; }
.email-export-pdf-body blockquote { margin-left: 12px; padding-left: 10px; border-left: 2px solid #ccc; color: #555; }
.email-export-pdf-body pre { white-space: pre-wrap; }
`;
page.appendChild(style);
const addField = (parent, label, value) => {
const addField = (doc, parent, label, value) => {
if (!value) return;
const row = document.createElement('div');
const row = doc.createElement('div');
row.className = 'email-export-pdf-field';
const labelEl = document.createElement('span');
const labelEl = doc.createElement('span');
labelEl.className = 'email-export-pdf-label';
labelEl.textContent = label;
const valueEl = document.createElement('span');
const valueEl = doc.createElement('span');
valueEl.textContent = value;
row.append(labelEl, valueEl);
parent.appendChild(row);
};
// Build sanitized HTML for the print frame -- scripts and event handlers
// cannot execute inside the sandboxed iframe.
const articles = [];
for (const { message, data } of loaded) {
const article = document.createElement('article');
article.className = 'email-export-pdf-message';
const subject = String(data.subject || message.subject || '(no subject)');
const title = document.createElement('h1');
title.textContent = subject;
article.appendChild(title);
const fields = document.createElement('div');
fields.className = 'email-export-pdf-fields';
const from = data.from_name && data.from_address
? `${data.from_name} <${data.from_address}>`
: data.from_name || data.from_address;
addField(fields, 'From', from);
addField(fields, 'To', data.to);
addField(fields, 'Cc', data.cc);
addField(fields, 'Date', data.date_display || data.date || message.date);
article.appendChild(fields);
const body = document.createElement('div');
body.className = 'email-export-pdf-body';
const safeBody = new DOMParser().parseFromString(_safeRenderEmailBody(data), 'text/html');
safeBody.querySelectorAll('img').forEach(img => {
const placeholder = safeBody.createElement('span');
placeholder.textContent = img.alt ? `[Image: ${img.alt}]` : '[Inline image omitted]';
img.replaceWith(placeholder);
});
body.innerHTML = safeBody.body.innerHTML;
body.querySelectorAll('details').forEach(detail => { detail.open = true; });
article.appendChild(body);
page.appendChild(article);
safeBody.querySelectorAll('details').forEach(detail => { detail.open = true; });
const bodyHtml = safeBody.body.innerHTML;
const fieldParts = [];
for (const [lbl, val] of [['From', from], ['To', data.to], ['Cc', data.cc], ['Date', data.date_display || data.date || message.date]]) {
if (!val) continue;
const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
fieldParts.push(`<div class="email-export-pdf-field"><span class="email-export-pdf-label">${esc(lbl)}</span><span>${esc(val)}</span></div>`);
}
const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
articles.push(
`<article class="email-export-pdf-message">` +
`<h1>${esc(subject)}</h1>` +
`<div class="email-export-pdf-fields">${fieldParts.join('')}</div>` +
`<div class="email-export-pdf-body">${bodyHtml}</div>` +
`</article>`
);
}
const printCss = [
'html,body{background:#fff;color:#171717;font:12px/1.55 Arial,Helvetica,sans-serif;margin:0;padding:0}',
'.email-export-pdf{max-width:794px;margin:0 auto;padding:38px;box-sizing:border-box}',
'.email-export-pdf-message{page-break-after:always;break-after:page}',
'.email-export-pdf-message:last-child{page-break-after:auto;break-after:auto}',
'.email-export-pdf-message h1{margin:0 0 18px;font-size:20px;line-height:1.3;overflow-wrap:anywhere}',
'.email-export-pdf-fields{margin:0 0 20px;padding:0 0 14px;border-bottom:1px solid #d8d8d8}',
'.email-export-pdf-field{display:flex;gap:10px;margin:3px 0;overflow-wrap:anywhere}',
'.email-export-pdf-label{width:54px;flex:0 0 54px;color:#666}',
'.email-export-pdf-body{overflow-wrap:anywhere;word-break:break-word}',
'.email-export-pdf-body img{max-width:100%}',
'.email-export-pdf-body a{color:#135f9b;text-decoration:underline}',
'.email-export-pdf-body blockquote{margin-left:12px;padding-left:10px;border-left:2px solid #ccc;color:#555}',
'.email-export-pdf-body pre{white-space:pre-wrap}',
'@page{margin:10mm}',
].join('\n');
const safeBaseName = messages.length === 1
? String(loaded[0]?.data?.subject || messages[0]?.subject || 'email').replace(/[^\p{L}\p{N} ._-]+/gu, '_').trim().slice(0, 100) || 'email'
: 'selected-emails';
// Use a sandboxed same-origin iframe following the established document.js
// Print / save PDF pattern (SAN-157). The browser opens a print dialog;
// saving, cancellation and pagination belong to the browser.
document.getElementById('email-browser-print-frame')?.remove();
const frame = document.createElement('iframe');
frame.id = 'email-browser-print-frame';
frame.title = 'Email print view';
frame.setAttribute('sandbox', 'allow-same-origin allow-modals');
frame.style.cssText = 'position:fixed;left:-10000px;top:0;width:800px;height:600px;border:0;';
document.body.appendChild(frame);
try {
await window.html2pdf().set({
margin: 10,
filename: `${safeBaseName}.pdf`,
image: { type: 'jpeg', quality: 0.92 },
html2canvas: { scale: 1.6, useCORS: false, backgroundColor: '#fff' },
jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' },
pagebreak: { mode: ['css', 'legacy'] },
}).from(page).save();
} finally {
page.remove();
const printDoc = frame.contentDocument;
printDoc.open();
printDoc.write('<!DOCTYPE html><html><head><meta charset="utf-8"></head><body></body></html>');
printDoc.close();
printDoc.title = safeBaseName;
const printStyle = printDoc.createElement('style');
printStyle.textContent = printCss;
printDoc.head.appendChild(printStyle);
const container = printDoc.createElement('div');
container.className = 'email-export-pdf';
container.innerHTML = articles.join('');
printDoc.body.appendChild(container);
await printDoc.fonts.ready;
frame.contentWindow.addEventListener('afterprint', () => frame.remove(), { once: true });
frame.contentWindow.focus();
frame.contentWindow.print();
} catch (error) {
frame.remove();
throw error;
}
}
@@ -1274,11 +1274,10 @@ async function _exportSelectedBundle(kind) {
if (cancelBtn) cancelBtn.disabled = true;
if (selectAll) selectAll.disabled = true;
const isEmailExport = kind === 'emails';
const isPdfExport = kind === 'pdf';
if (countEl) countEl.textContent = `${isPdfExport ? 'Creating PDF' : 'Exporting'} ${isPdfExport ? 'from' : isEmailExport ? 'emails from' : 'attachments from'} ${messages.length}…`;
if (isPdfExport) {
await _downloadSelectedEmailPdf(messages);
showToast(`Exported ${messages.length} email${messages.length === 1 ? '' : 's'} as PDF`);
const isPrintExport = kind === 'print';
if (countEl) countEl.textContent = `${isPrintExport ? 'Preparing print' : 'Exporting'} ${isPrintExport ? 'for' : isEmailExport ? 'emails from' : 'attachments from'} ${messages.length}…`;
if (isPrintExport) {
await _printSelectedEmails(messages);
return;
}
const res = await fetch(`${API_BASE}/api/email/${isEmailExport ? 'messages' : 'attachments'}-download-bulk`, {
@@ -1313,7 +1312,7 @@ async function _exportSelectedBundle(kind) {
: `Exported attachments from ${messages.length} selected email${messages.length === 1 ? '' : 's'}`);
} catch (err) {
console.error(`Failed to export selected email ${kind}:`, err);
showToast(err?.message || (kind === 'emails' ? 'Failed to export emails' : 'Failed to export attachments'));
showToast(err?.message || (kind === 'emails' ? 'Failed to export emails' : kind === 'print' ? 'Could not open browser print' : 'Failed to export attachments'));
} finally {
if (busySpinner) busySpinner.destroy();
if (actionsBtn) {
@@ -1334,7 +1333,7 @@ export async function _exportSelectedEmails() {
}
export async function _exportSelectedEmailPdf() {
return _exportSelectedBundle('pdf');
return _exportSelectedBundle('print');
}
export async function _exportSelectedAttachments() {
+1 -1
View File
@@ -619,7 +619,7 @@ export function _showBulkActionsMenu(anchor) {
dropdown.appendChild(divider);
const options = [
{ label: 'Email file (.eml)', icon: _emailIco, action: _exportSelectedEmails },
{ label: 'PDF', icon: _pdfIco, action: _exportSelectedEmailPdf },
{ label: 'Print / save PDF', icon: _pdfIco, action: _exportSelectedEmailPdf },
];
for (const option of options) {
const child = document.createElement('div');
+65 -8
View File
@@ -70,17 +70,74 @@ def test_email_bulk_exports_messages_and_attachments_from_selected_context():
assert '"X-Exported-Emails"' in backend
def test_email_bulk_pdf_export_reads_without_marking_read_and_sanitizes_body():
def test_email_print_pdf_export_reads_without_marking_read_and_sanitizes_body():
"""Print / save PDF export must use the browser print dialog (SAN-157).
The removed html2pdf library must not be loaded or referenced. Instead
the export uses a sandboxed iframe and window.print(), matching the
established pattern in document.js.
"""
frontend = email_library_source()
pdf_src = _function_source("_downloadSelectedEmailPdf")
print_src = _function_source("_printSelectedEmails")
menu_src = js_function_source("_showBulkActionsMenu", frontend)
assert "mark_seen=false&full=true" in pdf_src
assert "_safeRenderEmailBody(data)" in pdf_src
assert "new DOMParser()" in pdf_src
assert "window.html2pdf()" in pdf_src
assert "up to 20 emails" in pdf_src
assert "PDF" in menu_src
assert "mark_seen=false&full=true" in print_src
assert "_safeRenderEmailBody(data)" in print_src
assert "new DOMParser()" in print_src
assert "up to 20 emails" in print_src
assert "Print / save PDF" in menu_src
# SAN-157: must NOT reference the removed html2pdf library.
assert "html2pdf" not in print_src
assert "html2pdf" not in frontend
# Must use the browser print pattern with a sandboxed iframe.
assert "contentWindow.print()" in print_src
assert "sandbox" in print_src
assert "allow-same-origin" in print_src
assert "allow-modals" in print_src
assert "afterprint" in print_src
assert "email-browser-print-frame" in print_src
def test_email_eml_export_bounds_total_size():
"""The /messages-download-bulk endpoint must enforce a total size cap
to prevent unbounded memory growth when exporting many large messages."""
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
assert "max_total_bytes" in backend
assert "total_bytes" in backend
assert "Export too large" in backend
assert "status_code=413" in backend
def test_email_eml_export_validates_selection_count():
"""The /messages-download-bulk endpoint must reject oversized selections."""
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
assert 'len(messages) > 250' in backend
assert '"Too many emails selected"' in backend
def test_email_eml_export_rejects_non_numeric_remote_uids():
"""Non-numeric UIDs must not be passed to IMAP UID FETCH."""
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
# After fixture lookup fails, non-numeric UIDs are skipped.
assert 're.fullmatch(r"\\d+", uid)' in backend
def test_email_eml_export_handles_partial_batch_failures():
"""When an IMAP fetch batch returns partial results, exported/skipped
accounting must remain correct."""
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
# The skipped counter increments for UIDs not found in the batch response.
assert "skipped += len(batch) - len(found)" in backend
# Whole-connection failures count all rows as skipped.
assert "skipped += len(rows)" in backend
assert '"X-Exported-Emails"' in backend
assert '"X-Skipped-Emails"' in backend
def test_fixture_tag_filter_supports_receipt_tag_flow():