diff --git a/routes/email/email_routes.py b/routes/email/email_routes.py
index 99b429f6e..c3d08f785 100644
--- a/routes/email/email_routes.py
+++ b/routes/email/email_routes.py
@@ -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)
diff --git a/static/js/emailLibrary/index.js b/static/js/emailLibrary/index.js
index 7de542fe8..6dd013bf5 100644
--- a/static/js/emailLibrary/index.js
+++ b/static/js/emailLibrary/index.js
@@ -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, '>');
+ fieldParts.push(`
${esc(lbl)}${esc(val)}
`);
+ }
+
+ const esc = (s) => String(s).replace(/&/g, '&').replace(//g, '>');
+ articles.push(
+ `` +
+ `${esc(subject)}
` +
+ `${fieldParts.join('')}
` +
+ `${bodyHtml}
` +
+ ``
+ );
}
+ 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('');
+ 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() {
diff --git a/static/js/emailLibrary/menus.js b/static/js/emailLibrary/menus.js
index 3b03f4aba..57f7284e1 100644
--- a/static/js/emailLibrary/menus.js
+++ b/static/js/emailLibrary/menus.js
@@ -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');
diff --git a/tests/test_email_library_bulk_actions.py b/tests/test_email_library_bulk_actions.py
index 64e3f46c1..99e77313e 100644
--- a/tests/test_email_library_bulk_actions.py
+++ b/tests/test_email_library_bulk_actions.py
@@ -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():