mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-20 22:02:20 +02:00
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:
@@ -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)
|
||||
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
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() {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user