From 6cf676dfe29fc6d43e3abd012f4f36d58edcbc3d Mon Sep 17 00:00:00 2001 From: pewdiepie-archdaemon Date: Thu, 15 Oct 2026 12:02:06 +0000 Subject: [PATCH 1/7] feat(email): export selected messages as email or PDF --- routes/email/email_routes.py | 190 ++++++++++++++++++++++- static/js/emailLibrary/index.js | 165 +++++++++++++++++++- static/js/emailLibrary/menus.js | 46 +++++- tests/test_email_library_bulk_actions.py | 31 +++- 4 files changed, 415 insertions(+), 17 deletions(-) diff --git a/routes/email/email_routes.py b/routes/email/email_routes.py index 172790a6c..99b429f6e 100644 --- a/routes/email/email_routes.py +++ b/routes/email/email_routes.py @@ -26,12 +26,14 @@ import re import html import io import zipfile -from urllib.parse import parse_qs, unquote, urlparse +from urllib.parse import parse_qs, quote, unquote, urlparse from html.parser import HTMLParser as _HTMLParser import logging import uuid -from datetime import datetime +from datetime import datetime, timezone from pathlib import Path +from email.message import EmailMessage +from email import policy as email_policy from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart @@ -4185,6 +4187,190 @@ def setup_email_routes(): headers={"Content-Disposition": f'attachment; filename="{zip_name}"'}, ) + @router.post("/messages-download-bulk") + async def download_bulk_messages(request: Request, owner: str = Depends(require_owner)): + """Export selected original email messages as .eml files in a ZIP.""" + try: + payload = await request.json() + except Exception: + raise HTTPException(status_code=400, detail="Invalid JSON body") + messages = payload.get("messages") if isinstance(payload, dict) else None + if not isinstance(messages, list) or not messages: + raise HTTPException(status_code=400, detail="No emails selected") + if len(messages) > 250: + raise HTTPException(status_code=400, detail="Too many emails selected") + + category = str(payload.get("category") or "selected").strip().lower() + category = re.sub(r"[^a-z0-9_-]+", "-", category).strip("-") or "selected" + selected_dates = sorted({ + str(row.get("date") or "").strip() + for row in messages if isinstance(row, dict) + and re.fullmatch(r"\d{4}-\d{2}-\d{2}", str(row.get("date") or "").strip()) + }) + date_span = "" + if selected_dates: + date_span = selected_dates[0] if len(selected_dates) == 1 else f"{selected_dates[0]} - {selected_dates[-1]}" + export_folder = f"emails-{category}" + (f" ({date_span})" if date_span else "") + + zip_buf = io.BytesIO() + used_names: set[str] = set() + exported = 0 + skipped = 0 + single_export: tuple[str, bytes] | None = None + + def _email_filename(row: dict, uid: str) -> str: + date = str(row.get("date") or "").strip() + date = date if re.fullmatch(r"\d{4}-\d{2}-\d{2}", date) else "" + subject = str(row.get("subject") or "email").strip() + subject = re.sub(r'[<>:"/\\|?*\x00-\x1f\x7f]+', "_", subject) + subject = re.sub(r"\s+", " ", subject).strip(" .")[:100] or "email" + safe_uid = re.sub(r"[^A-Za-z0-9_-]+", "_", uid).strip("_") or "message" + prefix = f"{date} - " if date else "" + return _safe_attachment_zip_name(f"{prefix}{subject} - {safe_uid}.eml", "email.eml") + + def _fixture_message_bytes(fixture: dict, uid: str, folder: str) -> bytes: + msg = EmailMessage() + sender_name = str(fixture.get("from_name") or "").strip() + sender_address = str(fixture.get("from_address") or "").strip() + sender = email.utils.formataddr((sender_name, sender_address)) if sender_address else sender_name + for key, value in ( + ("From", sender), + ("To", fixture.get("to") or owner), + ("Subject", fixture.get("subject") or "(no subject)"), + ("Message-ID", fixture.get("message_id") or f""), + ): + clean_value = re.sub(r"[\r\n]+", " ", str(value or "")).strip() + if clean_value: + msg[key] = clean_value + raw_date = str(fixture.get("date") or "").strip() + if raw_date: + try: + parsed_date = datetime.fromisoformat(raw_date.replace("Z", "+00:00")) + if parsed_date.tzinfo is None: + parsed_date = parsed_date.replace(tzinfo=timezone.utc) + except Exception: + try: + parsed_date = email.utils.parsedate_to_datetime(raw_date) + except Exception: + parsed_date = None + if parsed_date is not None: + msg["Date"] = email.utils.format_datetime(parsed_date) + msg.set_content(str(fixture.get("body") or "")) + for attachment in fixture.get("attachments") or []: + try: + index = int(attachment.get("index")) + source = _fixture_attachment_source(uid, index, owner, folder) + if not source: + continue + _row, raw_attachment = source + content_type = str(raw_attachment.get("content_type") or "application/octet-stream") + maintype, _, subtype = content_type.partition("/") + if not subtype: + maintype, subtype = "application", "octet-stream" + msg.add_attachment( + str(raw_attachment.get("content") or "").encode("utf-8"), + maintype=maintype, + subtype=subtype, + filename=str(raw_attachment.get("filename") or attachment.get("filename") or f"attachment-{index}"), + ) + except Exception: + logger.debug("Skipping malformed fixture email attachment uid=%s", uid, exc_info=True) + return msg.as_bytes(policy=email_policy.SMTP) + + remote_groups: dict[tuple[str | None, str], dict[str, tuple[str, dict]]] = {} + with zipfile.ZipFile(zip_buf, "w", compression=zipfile.ZIP_DEFLATED) as zf: + for row in messages: + if not isinstance(row, dict): + skipped += 1 + continue + uid = str(row.get("uid") or "").strip() + if not uid: + skipped += 1 + continue + folder = str(row.get("folder") or "INBOX").strip() or "INBOX" + account_id = str(row.get("account_id") or row.get("account") or "").strip() or None + fixture = _fixture_email_read(uid, folder, owner) + if fixture is not None and not fixture.get("error"): + filename = _email_filename(row, uid) + unique_name = filename + suffix = 2 + while unique_name.lower() in used_names: + unique_name = f"{Path(filename).stem}-{suffix}.eml" + suffix += 1 + used_names.add(unique_name.lower()) + message_bytes = _fixture_message_bytes(fixture, uid, folder) + zf.writestr(f"{export_folder}/{unique_name}", message_bytes) + single_export = (unique_name, message_bytes) + exported += 1 + continue + if not re.fullmatch(r"\d+", uid): + skipped += 1 + continue + group = remote_groups.setdefault((account_id, folder), {}) + group.setdefault(uid, (_email_filename(row, uid), row)) + + # Fetch original RFC822 data in batches and store it byte-for-byte. + for (account_id, folder), rows_by_uid in remote_groups.items(): + rows = list(rows_by_uid.items()) + try: + with _imap(account_id, owner=owner) as conn: + conn.select(_q(folder), readonly=True) + for batch_start in range(0, len(rows), 20): + batch = rows[batch_start:batch_start + 20] + wanted = {uid: filename for uid, (filename, _row) in batch} + uid_set = ",".join(wanted).encode() + status, msg_data = _imap_uid_fetch(conn, uid_set, "(UID RFC822)") + if status != "OK" or not msg_data: + skipped += len(batch) + continue + found: set[str] = set() + for meta, raw_message in _group_uid_fetch_records(msg_data): + uid = _uid_from_fetch_meta(meta) + if uid not in wanted or not raw_message: + continue + filename = wanted[uid] + unique_name = filename + suffix = 2 + while unique_name.lower() in used_names: + unique_name = f"{Path(filename).stem}-{suffix}.eml" + suffix += 1 + used_names.add(unique_name.lower()) + message_bytes = bytes(raw_message) + zf.writestr(f"{export_folder}/{unique_name}", message_bytes) + single_export = (unique_name, message_bytes) + found.add(uid) + exported += 1 + skipped += len(batch) - len(found) + except Exception as exc: + skipped += len(rows) + logger.warning("Bulk email export skipped mailbox folder=%s account=%s count=%s: %s", folder, account_id, len(rows), exc) + + if exported <= 0: + raise HTTPException(status_code=404, detail="No selected emails could be exported") + if len(messages) == 1 and single_export: + filename, message_bytes = single_export + return StreamingResponse( + io.BytesIO(message_bytes), + media_type="message/rfc822", + headers={ + "Content-Disposition": f"attachment; filename=\"email.eml\"; filename*=UTF-8''{quote(filename, safe='')}", + "X-Exported-Emails": "1", + "X-Skipped-Emails": str(skipped), + }, + ) + zip_buf.seek(0) + zip_name = _safe_attachment_zip_name(f"{export_folder}.zip", "selected-emails.zip") + logger.info("Bulk email export complete owner=%s selected=%s exported=%s skipped=%s", owner, len(messages), exported, skipped) + return StreamingResponse( + zip_buf, + media_type="application/zip", + headers={ + "Content-Disposition": f'attachment; filename="{zip_name}"', + "X-Exported-Emails": str(exported), + "X-Skipped-Emails": str(skipped), + }, + ) + @router.get("/inline-image/{uid}") async def inline_image( uid: str, diff --git a/static/js/emailLibrary/index.js b/static/js/emailLibrary/index.js index 82f76b0c5..7de542fe8 100644 --- a/static/js/emailLibrary/index.js +++ b/static/js/emailLibrary/index.js @@ -1122,7 +1122,130 @@ function _filenameFromContentDisposition(header) { return bare ? bare[1].trim() : ''; } -export async function _exportSelectedAttachments() { +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(); + + const loaded = new Array(messages.length); + let nextIndex = 0; + const readWorker = async () => { + while (nextIndex < messages.length) { + const index = nextIndex++; + const message = messages[index]; + const account = message.account_id ? `&account_id=${encodeURIComponent(message.account_id)}` : ''; + const url = `${API_BASE}/api/email/read/${encodeURIComponent(message.uid)}?folder=${encodeURIComponent(message.folder || 'INBOX')}${account}&mark_seen=false&full=true`; + const response = await fetch(url, { credentials: 'same-origin' }); + if (!response.ok) throw new Error(`Could not load "${message.subject || 'email'}" (HTTP ${response.status})`); + const data = await response.json(); + if (data?.error) throw new Error(data.error); + loaded[index] = { message, data }; + } + }; + 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) => { + if (!value) return; + const row = document.createElement('div'); + row.className = 'email-export-pdf-field'; + const labelEl = document.createElement('span'); + labelEl.className = 'email-export-pdf-label'; + labelEl.textContent = label; + const valueEl = document.createElement('span'); + valueEl.textContent = value; + row.append(labelEl, valueEl); + parent.appendChild(row); + }; + + 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); + } + + 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'; + 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(); + } +} + +async function _exportSelectedBundle(kind) { const messages = _selectedEmailExportPayload(); if (!messages.length) { showToast('Select emails first'); @@ -1150,8 +1273,15 @@ export async function _exportSelectedAttachments() { if (deleteBtn) deleteBtn.disabled = true; if (cancelBtn) cancelBtn.disabled = true; if (selectAll) selectAll.disabled = true; - if (countEl) countEl.textContent = `Exporting attachments from ${messages.length}…`; - const res = await fetch(`${API_BASE}/api/email/attachments-download-bulk`, { + 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`); + return; + } + const res = await fetch(`${API_BASE}/api/email/${isEmailExport ? 'messages' : 'attachments'}-download-bulk`, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, @@ -1169,12 +1299,21 @@ export async function _exportSelectedAttachments() { throw new Error(detail || `HTTP ${res.status}`); } const blob = await res.blob(); - const filename = _filenameFromContentDisposition(res.headers.get('Content-Disposition')) || 'selected-email-attachments.zip'; + const filename = _filenameFromContentDisposition(res.headers.get('Content-Disposition')) + || (isEmailExport + ? (messages.length === 1 ? 'selected-email.eml' : 'selected-emails.zip') + : 'selected-email-attachments.zip'); _downloadBlob(blob, filename); - showToast(`Exported attachments from ${messages.length} selected email${messages.length === 1 ? '' : 's'}`); + const exported = Number(res.headers.get('X-Exported-Emails')) || messages.length; + const skipped = Number(res.headers.get('X-Skipped-Emails')) || 0; + showToast(skipped + ? `Exported ${exported} email${exported === 1 ? '' : 's'}; ${skipped} unavailable` + : isEmailExport + ? `Exported ${exported} email${exported === 1 ? '' : 's'} as ${exported === 1 ? 'an .eml file' : '.eml files'}` + : `Exported attachments from ${messages.length} selected email${messages.length === 1 ? '' : 's'}`); } catch (err) { - console.error('Failed to export selected email attachments:', err); - showToast(err?.message || 'Failed to export attachments'); + console.error(`Failed to export selected email ${kind}:`, err); + showToast(err?.message || (kind === 'emails' ? 'Failed to export emails' : 'Failed to export attachments')); } finally { if (busySpinner) busySpinner.destroy(); if (actionsBtn) { @@ -1190,6 +1329,18 @@ export async function _exportSelectedAttachments() { } } +export async function _exportSelectedEmails() { + return _exportSelectedBundle('emails'); +} + +export async function _exportSelectedEmailPdf() { + return _exportSelectedBundle('pdf'); +} + +export async function _exportSelectedAttachments() { + return _exportSelectedBundle('attachments'); +} + // URL-suffix helper — appends &account_id=... when an account is actively selected. // Every email route call in this file goes through here so switching accounts // is a single-variable flip. diff --git a/static/js/emailLibrary/menus.js b/static/js/emailLibrary/menus.js index da859ecad..3b03f4aba 100644 --- a/static/js/emailLibrary/menus.js +++ b/static/js/emailLibrary/menus.js @@ -25,6 +25,8 @@ import { _animateEmailCardRemoval, _clearDoneResponseTagsLocal, _emailMutationQuery, + _exportSelectedEmailPdf, + _exportSelectedEmails, _exportSelectedAttachments, _findSiblingEmailCard, _fitEmailDropdown, @@ -585,7 +587,10 @@ export function _showBulkActionsMenu(anchor) { const _unreadIco = ''; const _doneIco = ''; const _exportIco = ''; + const _emailIco = ''; + const _pdfIco = ''; const items = [ + { label: 'Export Emails', icon: _exportIco, submenu: true }, { label: 'Export Attachments', icon: _exportIco, action: () => _exportSelectedAttachments() }, { label: 'Done', icon: _doneIco, action: () => _bulkAction('done') }, { label: 'Mark Read', icon: _readIco, action: () => _bulkAction('read') }, @@ -594,8 +599,45 @@ export function _showBulkActionsMenu(anchor) { for (const a of items) { const it = document.createElement('div'); it.className = 'dropdown-item-compact' + (a.danger ? ' dropdown-item-danger' : ''); - it.innerHTML = `${a.icon}${a.label}`; - it.addEventListener('click', (e) => { e.stopPropagation(); close(); a.action(); }); + it.className += a.submenu ? ' email-bulk-submenu-trigger' : ''; + it.innerHTML = `${a.icon}${a.label}${a.submenu ? '›' : ''}`; + it.addEventListener('click', (e) => { + e.stopPropagation(); + if (a.submenu) { + dropdown.replaceChildren(); + const back = document.createElement('div'); + back.className = 'dropdown-item-compact email-bulk-submenu-back'; + back.innerHTML = '‹Export Emails'; + back.addEventListener('click', (ev) => { + ev.stopPropagation(); + close(); + _showBulkActionsMenu(anchor); + }); + dropdown.appendChild(back); + const divider = document.createElement('div'); + divider.className = 'dropdown-divider'; + dropdown.appendChild(divider); + const options = [ + { label: 'Email file (.eml)', icon: _emailIco, action: _exportSelectedEmails }, + { label: 'PDF', icon: _pdfIco, action: _exportSelectedEmailPdf }, + ]; + for (const option of options) { + const child = document.createElement('div'); + child.className = 'dropdown-item-compact'; + child.innerHTML = `${option.icon}${option.label}`; + child.addEventListener('click', (ev) => { + ev.stopPropagation(); + close(); + option.action(); + }); + dropdown.appendChild(child); + } + _fitEmailDropdown(dropdown, anchor.getBoundingClientRect()); + return; + } + close(); + a.action(); + }); dropdown.appendChild(it); } // Mobile-only Cancel — matches the per-card and sidebar dropdowns. diff --git a/tests/test_email_library_bulk_actions.py b/tests/test_email_library_bulk_actions.py index 4e9930678..64e3f46c1 100644 --- a/tests/test_email_library_bulk_actions.py +++ b/tests/test_email_library_bulk_actions.py @@ -41,18 +41,18 @@ def test_email_bulk_read_unread_checks_backend_success_before_syncing_cache(): assert "_libCacheWriteBack()" in src -def test_email_bulk_export_attachments_is_ui_only_selected_context(): +def test_email_bulk_exports_messages_and_attachments_from_selected_context(): frontend = email_library_source() backend = _EMAIL_ROUTES.read_text(encoding="utf-8") - export_src = frontend[ - frontend.index("async function _exportSelectedAttachments()"): - frontend.index("\n}\n\n// URL-suffix helper", frontend.index("async function _exportSelectedAttachments()")) + 3 - ] + export_src = _function_source("_exportSelectedBundle") payload_src = _function_source("_selectedEmailExportPayload") + assert "Export Emails" in frontend + assert "Email file (.eml)" in frontend + assert "_exportSelectedEmailPdf" in frontend assert "Export Attachments" in frontend assert "All in view" in frontend - assert "/api/email/attachments-download-bulk" in export_src + assert "${isEmailExport ? 'messages' : 'attachments'}-download-bulk" in export_src assert "method: 'POST'" in export_src assert "res.blob()" in export_src assert "account_id: String(em.account_id || state._libAccountId || '')" in payload_src @@ -63,6 +63,25 @@ def test_email_bulk_export_attachments_is_ui_only_selected_context(): assert "_fixture_email_read(uid, folder, owner)" in backend assert "_is_likely_signature_image_attachment(att)" in backend + assert '@router.post("/messages-download-bulk")' in backend + assert '"(UID RFC822)"' in backend + assert "msg.as_bytes(policy=email_policy.SMTP)" in backend + assert 'media_type="message/rfc822"' in backend + assert '"X-Exported-Emails"' in backend + + +def test_email_bulk_pdf_export_reads_without_marking_read_and_sanitizes_body(): + frontend = email_library_source() + pdf_src = _function_source("_downloadSelectedEmailPdf") + 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 + def test_fixture_tag_filter_supports_receipt_tag_flow(): backend = _EMAIL_ROUTES.read_text(encoding="utf-8") From 8eaf742b431e3771b811bbae3dfbc63fe082defe Mon Sep 17 00:00:00 2001 From: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com> Date: Tue, 20 Oct 2026 10:14:55 +0100 Subject: [PATCH 2/7] 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 --- routes/email/email_routes.py | 10 ++ static/js/emailLibrary/index.js | 163 +++++++++++------------ static/js/emailLibrary/menus.js | 2 +- tests/test_email_library_bulk_actions.py | 73 ++++++++-- 4 files changed, 157 insertions(+), 91 deletions(-) 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(``); + } + + const esc = (s) => String(s).replace(/&/g, '&').replace(//g, '>'); + articles.push( + `` + ); } + 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(): From 550ac015eb88e5eec8c538cffa49fda55bfbe14b Mon Sep 17 00:00:00 2001 From: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com> Date: Tue, 20 Oct 2026 10:35:30 +0100 Subject: [PATCH 3/7] fix(email): enforce account ownership and correct partial failure accounting - Validate account ownership with _assert_owns_account for every account_id - Deduplicate items in selection to prevent duplicate files and skewed accounting - Fix exception handling in IMAP batching to avoid double-counting on partial failures - Re-raise HTTPException so 413 memory cap errors are not swallowed - Remove unused addField helper in emailLibrary print exporter - Add behavioral test suite covering empty/oversized validation, rfc822 export, zip export, partial failure/dedup, and cross-tenant account isolation --- routes/email/email_routes.py | 19 ++- static/js/emailLibrary/index.js | 13 -- tests/test_email_library_bulk_actions.py | 168 ++++++++++++++++++++++- 3 files changed, 183 insertions(+), 17 deletions(-) diff --git a/routes/email/email_routes.py b/routes/email/email_routes.py index c3d08f785..0af2d0a11 100644 --- a/routes/email/email_routes.py +++ b/routes/email/email_routes.py @@ -4281,6 +4281,7 @@ def setup_email_routes(): logger.debug("Skipping malformed fixture email attachment uid=%s", uid, exc_info=True) return msg.as_bytes(policy=email_policy.SMTP) + seen_items: set[tuple[str | None, str, str]] = set() remote_groups: dict[tuple[str | None, str], dict[str, tuple[str, dict]]] = {} with zipfile.ZipFile(zip_buf, "w", compression=zipfile.ZIP_DEFLATED) as zf: for row in messages: @@ -4293,6 +4294,15 @@ def setup_email_routes(): continue folder = str(row.get("folder") or "INBOX").strip() or "INBOX" account_id = str(row.get("account_id") or row.get("account") or "").strip() or None + if account_id: + _assert_owns_account(account_id, owner) + + item_key = (account_id, folder, uid) + if item_key in seen_items: + skipped += 1 + continue + seen_items.add(item_key) + fixture = _fixture_email_read(uid, folder, owner) if fixture is not None and not fixture.get("error"): filename = _email_filename(row, uid) @@ -4319,6 +4329,7 @@ def setup_email_routes(): # Fetch original RFC822 data in batches and store it byte-for-byte. for (account_id, folder), rows_by_uid in remote_groups.items(): rows = list(rows_by_uid.items()) + remaining_rows = len(rows) try: with _imap(account_id, owner=owner) as conn: conn.select(_q(folder), readonly=True) @@ -4329,6 +4340,7 @@ def setup_email_routes(): status, msg_data = _imap_uid_fetch(conn, uid_set, "(UID RFC822)") if status != "OK" or not msg_data: skipped += len(batch) + remaining_rows -= len(batch) continue found: set[str] = set() for meta, raw_message in _group_uid_fetch_records(msg_data): @@ -4351,9 +4363,12 @@ def setup_email_routes(): found.add(uid) exported += 1 skipped += len(batch) - len(found) + remaining_rows -= len(batch) + except HTTPException: + raise except Exception as exc: - skipped += len(rows) - logger.warning("Bulk email export skipped mailbox folder=%s account=%s count=%s: %s", folder, account_id, len(rows), exc) + skipped += remaining_rows + logger.warning("Bulk email export skipped mailbox folder=%s account=%s count=%s: %s", folder, account_id, remaining_rows, exc) if exported <= 0: raise HTTPException(status_code=404, detail="No selected emails could be exported") diff --git a/static/js/emailLibrary/index.js b/static/js/emailLibrary/index.js index 6dd013bf5..543fdfd55 100644 --- a/static/js/emailLibrary/index.js +++ b/static/js/emailLibrary/index.js @@ -1143,19 +1143,6 @@ async function _printSelectedEmails(messages) { }; await Promise.all(Array.from({ length: Math.min(4, messages.length) }, readWorker)); - const addField = (doc, parent, label, value) => { - if (!value) return; - const row = doc.createElement('div'); - row.className = 'email-export-pdf-field'; - const labelEl = doc.createElement('span'); - labelEl.className = 'email-export-pdf-label'; - labelEl.textContent = label; - 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 = []; diff --git a/tests/test_email_library_bulk_actions.py b/tests/test_email_library_bulk_actions.py index 99e77313e..6840b5916 100644 --- a/tests/test_email_library_bulk_actions.py +++ b/tests/test_email_library_bulk_actions.py @@ -134,12 +134,26 @@ def test_email_eml_export_handles_partial_batch_failures(): # 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 + # Whole-connection failures count remaining unprocessed rows as skipped. + assert "skipped += remaining_rows" in backend assert '"X-Exported-Emails"' in backend assert '"X-Skipped-Emails"' in backend +def test_email_eml_export_enforces_account_ownership(): + """The /messages-download-bulk endpoint must assert account ownership.""" + backend = _EMAIL_ROUTES.read_text(encoding="utf-8") + + assert "_assert_owns_account(account_id, owner)" in backend + + +def test_email_eml_export_deduplicates_selection(): + """Duplicate items in selection must be skipped to avoid duplicate files.""" + backend = _EMAIL_ROUTES.read_text(encoding="utf-8") + + assert "seen_items" in backend + + def test_fixture_tag_filter_supports_receipt_tag_flow(): backend = _EMAIL_ROUTES.read_text(encoding="utf-8") @@ -247,3 +261,153 @@ def test_email_compose_can_attach_gallery_images(): assert "GalleryImage" in backend assert "_gallery_image_path(img.filename)" in backend assert "_stage_compose_file" in backend + + +class _MockImapConn: + def __init__(self, messages_by_uid: dict[str, bytes] | None = None, fetch_exc: Exception | None = None): + self.messages = messages_by_uid or {} + self.fetch_exc = fetch_exc + + def select(self, folder, readonly=True): + return "OK", [b"1"] + + def uid(self, command, *args): + if command == "FETCH": + if self.fetch_exc is not None: + raise self.fetch_exc + uid_set_b = args[0] + uid_set = uid_set_b.decode() if isinstance(uid_set_b, bytes) else str(uid_set_b) + uids = [u.strip() for u in uid_set.split(",") if u.strip()] + res = [] + for u in uids: + if u in self.messages: + meta = f"1 (UID {u} RFC822 {{{len(self.messages[u])}}}".encode() + res.append((meta, self.messages[u])) + res.append(b")") + return "OK", res + return "NO", [] + + +def _create_test_app(monkeypatch, mailbox: _MockImapConn | None = None, assert_owns_fn=None): + from contextlib import contextmanager + from fastapi import FastAPI + from fastapi.testclient import TestClient + from routes.email import email_routes as routes + + conn = mailbox or _MockImapConn() + + @contextmanager + def mock_imap(account_id=None, owner=""): + yield conn + + monkeypatch.setattr(routes, "_start_poller", lambda: None) + monkeypatch.setattr(routes, "_POOL_HOOKS", {}) + monkeypatch.setattr(routes, "_imap", mock_imap) + if assert_owns_fn: + monkeypatch.setattr(routes, "_assert_owns_account", assert_owns_fn) + + app = FastAPI() + app.include_router(routes.setup_email_routes()) + app.dependency_overrides[routes.require_owner] = lambda: "test-user" + return TestClient(app) + + +def test_messages_download_bulk_validates_empty_and_oversized_payload(monkeypatch): + client = _create_test_app(monkeypatch) + + res_empty = client.post("/api/email/messages-download-bulk", json={"messages": []}) + assert res_empty.status_code == 400 + assert "No emails selected" in res_empty.text + + oversized = [{"uid": str(i)} for i in range(251)] + res_over = client.post("/api/email/messages-download-bulk", json={"messages": oversized}) + assert res_over.status_code == 400 + assert "Too many emails selected" in res_over.text + + +def test_messages_download_bulk_single_message_eml_export(monkeypatch): + raw_rfc822 = b"From: sender@example.com\r\nTo: test-user\r\nSubject: Report\r\n\r\nReport content" + mailbox = _MockImapConn({"101": raw_rfc822}) + client = _create_test_app(monkeypatch, mailbox=mailbox) + + res = client.post("/api/email/messages-download-bulk", json={ + "messages": [{"uid": "101", "subject": "Quarterly Report", "date": "2026-10-20"}] + }) + + assert res.status_code == 200 + assert res.headers["content-type"].startswith("message/rfc822") + assert res.headers["x-exported-emails"] == "1" + assert res.headers["x-skipped-emails"] == "0" + assert "email.eml" in res.headers["content-disposition"] + assert res.content == raw_rfc822 + + +def test_messages_download_bulk_multi_message_zip_export(monkeypatch): + import io + import zipfile + + msg1 = b"From: a@example.com\r\nSubject: First\r\n\r\nOne" + msg2 = b"From: b@example.com\r\nSubject: Second\r\n\r\nTwo" + mailbox = _MockImapConn({"101": msg1, "102": msg2}) + client = _create_test_app(monkeypatch, mailbox=mailbox) + + res = client.post("/api/email/messages-download-bulk", json={ + "messages": [ + {"uid": "101", "subject": "First Msg", "date": "2026-10-18"}, + {"uid": "102", "subject": "Second Msg", "date": "2026-10-19"}, + ] + }) + + assert res.status_code == 200 + assert res.headers["content-type"] == "application/zip" + assert res.headers["x-exported-emails"] == "2" + assert res.headers["x-skipped-emails"] == "0" + + zf = zipfile.ZipFile(io.BytesIO(res.content)) + namelist = zf.namelist() + assert len(namelist) == 2 + assert any("101.eml" in name for name in namelist) + assert any("102.eml" in name for name in namelist) + + +def test_messages_download_bulk_partial_failure_and_duplicate_dedup(monkeypatch): + import io + import zipfile + + msg1 = b"From: a@example.com\r\nSubject: Exists\r\n\r\nExists body" + # UID 101 exists, UID 999 does not exist in mailbox + mailbox = _MockImapConn({"101": msg1}) + client = _create_test_app(monkeypatch, mailbox=mailbox) + + res = client.post("/api/email/messages-download-bulk", json={ + "messages": [ + {"uid": "101", "subject": "Exists"}, + {"uid": "999", "subject": "Missing"}, + {"uid": "101", "subject": "Duplicate"}, # duplicate must be skipped + ] + }) + + assert res.status_code == 200 + assert res.headers["content-type"] == "application/zip" + assert res.headers["x-exported-emails"] == "1" + assert res.headers["x-skipped-emails"] == "2" + + zf = zipfile.ZipFile(io.BytesIO(res.content)) + assert len(zf.namelist()) == 1 + + +def test_messages_download_bulk_enforces_account_ownership_guard(monkeypatch): + from fastapi import HTTPException + + def mock_assert_owns(account_id, owner): + if account_id == "other-tenant-acct": + raise HTTPException(status_code=404, detail="Account not found") + + client = _create_test_app(monkeypatch, assert_owns_fn=mock_assert_owns) + + res = client.post("/api/email/messages-download-bulk", json={ + "messages": [{"uid": "101", "account_id": "other-tenant-acct"}] + }) + + assert res.status_code == 404 + assert "Account not found" in res.text From c7d1555eea99c388d60a0d0c3ae9a12d4e63ba43 Mon Sep 17 00:00:00 2001 From: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:38:12 +0100 Subject: [PATCH 4/7] fix(email): resolve memory safety, print rendering, and accounting defects - Use SpooledTemporaryFile with 32 MiB spool threshold for export ZIP archives - Add IMAP size preflight via (UID RFC822.SIZE) and cap batch payload at 10 MiB - Enforce 64 MiB per-message ceiling and fail closed with 500 on corrupted ZIP - Stream spooled archive chunks with generator cleanup on disconnect/cancellation - Bypass ZIP creation for single raw EML message downloads - Memoize account ownership checks per request to eliminate N+1 database queries - Replace interactive image placeholders in print view with clean text notices - Strip interactive buttons, icons, and image actions from print document - Implement partial print success policy printing loaded items and reporting omitted - Harden print iframe lifecycle with window afterprint listener and fallback timer - Correct mid-batch exception accounting so exported plus skipped equals requested - Add executable behavioral tests for limits, mid-batch accounting, and memoization --- routes/email/email_routes.py | 338 ++++++++++++++++------- static/js/emailLibrary/index.js | 90 ++++-- tests/test_email_library_bulk_actions.py | 158 +++++++++++ 3 files changed, 469 insertions(+), 117 deletions(-) diff --git a/routes/email/email_routes.py b/routes/email/email_routes.py index 0af2d0a11..541a7189f 100644 --- a/routes/email/email_routes.py +++ b/routes/email/email_routes.py @@ -25,6 +25,7 @@ import json import re import html import io +import tempfile import zipfile from urllib.parse import parse_qs, quote, unquote, urlparse from html.parser import HTMLParser as _HTMLParser @@ -642,6 +643,11 @@ def _uid_from_fetch_meta(meta_b: bytes) -> str: return m.group(1).decode() if m else "" +def _size_from_fetch_meta(meta_b: bytes) -> int | None: + m = re.search(rb"\bRFC822\.SIZE\s+(\d+)\b", meta_b, re.IGNORECASE) + return int(m.group(1)) if m else None + + def _parse_list_unsubscribe_header(value: str | None) -> list[dict]: """Parse RFC List-Unsubscribe entries into safe reviewable actions. @@ -4212,15 +4218,16 @@ def setup_email_routes(): date_span = selected_dates[0] if len(selected_dates) == 1 else f"{selected_dates[0]} - {selected_dates[-1]}" export_folder = f"emails-{category}" + (f" ({date_span})" if date_span else "") - zip_buf = io.BytesIO() + # 512 MiB safety cap prevents unbounded memory growth when exporting + # many large messages. + max_total_bytes = 512 * 1024 * 1024 + max_message_bytes = 64 * 1024 * 1024 + spool_threshold_bytes = 32 * 1024 * 1024 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 + validated_accounts: set[str] = set() def _email_filename(row: dict, uid: str) -> str: date = str(row.get("date") or "").strip() @@ -4281,120 +4288,249 @@ def setup_email_routes(): logger.debug("Skipping malformed fixture email attachment uid=%s", uid, exc_info=True) return msg.as_bytes(policy=email_policy.SMTP) - seen_items: set[tuple[str | None, str, str]] = set() - remote_groups: dict[tuple[str | None, str], dict[str, tuple[str, dict]]] = {} - with zipfile.ZipFile(zip_buf, "w", compression=zipfile.ZIP_DEFLATED) as zf: - for row in messages: - if not isinstance(row, dict): - skipped += 1 - continue - uid = str(row.get("uid") or "").strip() - if not uid: - skipped += 1 - continue - folder = str(row.get("folder") or "INBOX").strip() or "INBOX" - account_id = str(row.get("account_id") or row.get("account") or "").strip() or None - if account_id: - _assert_owns_account(account_id, owner) + # Optimization: single message exports return raw .eml directly without ZIP compression. + if len(messages) == 1: + row = messages[0] + if not isinstance(row, dict): + raise HTTPException(status_code=404, detail="No selected emails could be exported") + uid = str(row.get("uid") or "").strip() + if not uid: + raise HTTPException(status_code=404, detail="No selected emails could be exported") + folder = str(row.get("folder") or "INBOX").strip() or "INBOX" + account_id = str(row.get("account_id") or row.get("account") or "").strip() or None + if account_id: + _assert_owns_account(account_id, owner) - item_key = (account_id, folder, uid) - if item_key in seen_items: - skipped += 1 - continue - seen_items.add(item_key) + filename = _email_filename(row, uid) + message_bytes: bytes | None = None - fixture = _fixture_email_read(uid, folder, owner) - if fixture is not None and not fixture.get("error"): - filename = _email_filename(row, uid) - unique_name = filename - suffix = 2 - while unique_name.lower() in used_names: - unique_name = f"{Path(filename).stem}-{suffix}.eml" - 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 - continue - if not re.fullmatch(r"\d+", uid): - skipped += 1 - continue - group = remote_groups.setdefault((account_id, folder), {}) - group.setdefault(uid, (_email_filename(row, uid), row)) - - # Fetch original RFC822 data in batches and store it byte-for-byte. - for (account_id, folder), rows_by_uid in remote_groups.items(): - rows = list(rows_by_uid.items()) - remaining_rows = len(rows) + fixture = _fixture_email_read(uid, folder, owner) + if fixture is not None and not fixture.get("error"): + message_bytes = _fixture_message_bytes(fixture, uid, folder) + elif re.fullmatch(r"\d+", uid): try: with _imap(account_id, owner=owner) as conn: conn.select(_q(folder), readonly=True) - for batch_start in range(0, len(rows), 20): - batch = rows[batch_start:batch_start + 20] - wanted = {uid: filename for uid, (filename, _row) in batch} - uid_set = ",".join(wanted).encode() - status, msg_data = _imap_uid_fetch(conn, uid_set, "(UID RFC822)") - if status != "OK" or not msg_data: - skipped += len(batch) - remaining_rows -= len(batch) - continue - found: set[str] = set() - for meta, raw_message in _group_uid_fetch_records(msg_data): - uid = _uid_from_fetch_meta(meta) - if uid not in wanted or not raw_message: - continue - filename = wanted[uid] - unique_name = filename - suffix = 2 - while unique_name.lower() in used_names: - unique_name = f"{Path(filename).stem}-{suffix}.eml" - 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) - exported += 1 - skipped += len(batch) - len(found) - remaining_rows -= len(batch) + uid_b = uid.encode() + try: + st_sz, sz_data = _imap_uid_fetch(conn, uid_b, "(UID RFC822.SIZE)") + if st_sz == "OK" and sz_data: + for item in sz_data: + meta = item[0] if isinstance(item, tuple) else item + if isinstance(meta, bytes): + sz = _size_from_fetch_meta(meta) + if sz is not None and (sz > max_message_bytes or sz > max_total_bytes): + raise HTTPException(status_code=413, detail="Export too large") + except HTTPException: + raise + except Exception as sz_err: + logger.debug("Single message size preflight error: %s", sz_err) + + st, msg_data = _imap_uid_fetch(conn, uid_b, "(UID RFC822)") + if st == "OK" and msg_data: + for meta, raw in _group_uid_fetch_records(msg_data): + if _uid_from_fetch_meta(meta) == uid and raw: + message_bytes = bytes(raw) + break except HTTPException: raise except Exception as exc: - skipped += remaining_rows - logger.warning("Bulk email export skipped mailbox folder=%s account=%s count=%s: %s", folder, account_id, remaining_rows, exc) + logger.warning("Single email export failed for uid=%s folder=%s: %s", uid, folder, exc) + + if not message_bytes: + raise HTTPException(status_code=404, detail="No selected emails could be exported") + + if len(message_bytes) > max_message_bytes or len(message_bytes) > max_total_bytes: + raise HTTPException(status_code=413, detail="Export too large") - if exported <= 0: - raise HTTPException(status_code=404, detail="No selected emails could be exported") - if len(messages) == 1 and single_export: - filename, message_bytes = single_export return StreamingResponse( io.BytesIO(message_bytes), media_type="message/rfc822", headers={ "Content-Disposition": f"attachment; filename=\"email.eml\"; filename*=UTF-8''{quote(filename, safe='')}", "X-Exported-Emails": "1", + "X-Skipped-Emails": "0", + }, + ) + + seen_items: set[tuple[str | None, str, str]] = set() + remote_groups: dict[tuple[str | None, str], dict[str, tuple[str, dict]]] = {} + spooled_file = tempfile.SpooledTemporaryFile(max_size=spool_threshold_bytes, mode="w+b") + response_started = False + try: + with zipfile.ZipFile(spooled_file, "w", compression=zipfile.ZIP_DEFLATED) as zf: + for row in messages: + if not isinstance(row, dict): + skipped += 1 + continue + uid = str(row.get("uid") or "").strip() + if not uid: + skipped += 1 + continue + folder = str(row.get("folder") or "INBOX").strip() or "INBOX" + account_id = str(row.get("account_id") or row.get("account") or "").strip() or None + if account_id and account_id not in validated_accounts: + _assert_owns_account(account_id, owner) + validated_accounts.add(account_id) + + item_key = (account_id, folder, uid) + if item_key in seen_items: + skipped += 1 + continue + seen_items.add(item_key) + + fixture = _fixture_email_read(uid, folder, owner) + if fixture is not None and not fixture.get("error"): + filename = _email_filename(row, uid) + unique_name = filename + suffix = 2 + while unique_name.lower() in used_names: + unique_name = f"{Path(filename).stem}-{suffix}.eml" + suffix += 1 + used_names.add(unique_name.lower()) + message_bytes = _fixture_message_bytes(fixture, uid, folder) + if len(message_bytes) > max_message_bytes: + raise HTTPException(status_code=413, detail="Export too large") + total_bytes += len(message_bytes) + if total_bytes > max_total_bytes: + raise HTTPException(status_code=413, detail="Export too large") + try: + zf.writestr(f"{export_folder}/{unique_name}", message_bytes) + except Exception as zip_err: + logger.error("ZIP entry write failed for fixture: %s", zip_err) + raise HTTPException(status_code=500, detail="Failed to create export archive") + exported += 1 + continue + if not re.fullmatch(r"\d+", uid): + skipped += 1 + continue + group = remote_groups.setdefault((account_id, folder), {}) + group.setdefault(uid, (_email_filename(row, uid), row)) + + # Fetch original RFC822 data in batches and store it byte-for-byte. + for (account_id, folder), rows_by_uid in remote_groups.items(): + rows = list(rows_by_uid.items()) + remaining_rows = len(rows) + try: + with _imap(account_id, owner=owner) as conn: + conn.select(_q(folder), readonly=True) + + # Size preflight query to catch oversized exports early and bound batch sizes. + uid_set_all = ",".join(uid for uid, _ in rows).encode() + size_by_uid: dict[str, int] = {} + try: + st_sz, sz_data = _imap_uid_fetch(conn, uid_set_all, "(UID RFC822.SIZE)") + if st_sz == "OK" and sz_data: + for item in sz_data: + meta = item[0] if isinstance(item, tuple) else item + if isinstance(meta, bytes): + u = _uid_from_fetch_meta(meta) + s = _size_from_fetch_meta(meta) + if u and s is not None: + size_by_uid[u] = s + except Exception as sz_err: + logger.debug("IMAP size preflight query failed or unsupported: %s", sz_err) + + if size_by_uid: + for u, s in size_by_uid.items(): + if s > max_message_bytes: + raise HTTPException(status_code=413, detail="Export too large") + if total_bytes + sum(size_by_uid.values()) > max_total_bytes: + raise HTTPException(status_code=413, detail="Export too large") + + # Form batches with at most 20 messages and capped at 10 MiB payload. + batches: list[list[tuple[str, tuple[str, dict]]]] = [] + cur_batch: list[tuple[str, tuple[str, dict]]] = [] + cur_batch_bytes = 0 + max_batch_bytes = 10 * 1024 * 1024 + for item in rows: + uid_val = item[0] + sz = size_by_uid.get(uid_val, 0) + if cur_batch and ( + len(cur_batch) >= 20 or + (sz > 0 and cur_batch_bytes + sz > max_batch_bytes) + ): + batches.append(cur_batch) + cur_batch = [] + cur_batch_bytes = 0 + cur_batch.append(item) + cur_batch_bytes += sz + if cur_batch: + batches.append(cur_batch) + + for batch in batches: + wanted = {uid: filename for uid, (filename, _row) in batch} + uid_set = ",".join(wanted).encode() + status, msg_data = _imap_uid_fetch(conn, uid_set, "(UID RFC822)") + if status != "OK" or not msg_data: + skipped += len(batch) + remaining_rows -= len(batch) + continue + found: set[str] = set() + for meta, raw_message in _group_uid_fetch_records(msg_data): + uid = _uid_from_fetch_meta(meta) + if uid not in wanted or not raw_message: + continue + filename = wanted[uid] + unique_name = filename + suffix = 2 + while unique_name.lower() in used_names: + unique_name = f"{Path(filename).stem}-{suffix}.eml" + suffix += 1 + used_names.add(unique_name.lower()) + message_bytes = bytes(raw_message) + if len(message_bytes) > max_message_bytes: + raise HTTPException(status_code=413, detail="Export too large") + total_bytes += len(message_bytes) + if total_bytes > max_total_bytes: + raise HTTPException(status_code=413, detail="Export too large") + try: + zf.writestr(f"{export_folder}/{unique_name}", message_bytes) + except Exception as zip_err: + logger.error("ZIP entry write failed for remote message: %s", zip_err) + raise HTTPException(status_code=500, detail="Failed to create export archive") + found.add(uid) + exported += 1 + remaining_rows -= 1 + missing = len(batch) - len(found) + skipped += len(batch) - len(found) + remaining_rows -= missing + except HTTPException: + raise + except Exception as exc: + skipped += remaining_rows + logger.warning("Bulk email export skipped mailbox folder=%s account=%s count=%s: %s", folder, account_id, remaining_rows, exc) + + if exported <= 0: + raise HTTPException(status_code=404, detail="No selected emails could be exported") + + spooled_file.seek(0) + zip_name = _safe_attachment_zip_name(f"{export_folder}.zip", "selected-emails.zip") + logger.info("Bulk email export complete owner=%s selected=%s exported=%s skipped=%s", owner, len(messages), exported, skipped) + + def _iter_spooled(f, chunk_size=64 * 1024): + try: + while True: + chunk = f.read(chunk_size) + if not chunk: + break + yield chunk + finally: + f.close() + + response_started = True + return StreamingResponse( + _iter_spooled(spooled_file), + media_type="application/zip", + headers={ + "Content-Disposition": f'attachment; filename="{zip_name}"', + "X-Exported-Emails": str(exported), "X-Skipped-Emails": str(skipped), }, ) - zip_buf.seek(0) - zip_name = _safe_attachment_zip_name(f"{export_folder}.zip", "selected-emails.zip") - logger.info("Bulk email export complete owner=%s selected=%s exported=%s skipped=%s", owner, len(messages), exported, skipped) - return StreamingResponse( - zip_buf, - media_type="application/zip", - headers={ - "Content-Disposition": f'attachment; filename="{zip_name}"', - "X-Exported-Emails": str(exported), - "X-Skipped-Emails": str(skipped), - }, - ) + finally: + if not response_started: + spooled_file.close() @router.get("/inline-image/{uid}") async def inline_image( diff --git a/static/js/emailLibrary/index.js b/static/js/emailLibrary/index.js index 543fdfd55..cc08f2d0c 100644 --- a/static/js/emailLibrary/index.js +++ b/static/js/emailLibrary/index.js @@ -1132,32 +1132,70 @@ async function _printSelectedEmails(messages) { while (nextIndex < messages.length) { const index = nextIndex++; const message = messages[index]; - const account = message.account_id ? `&account_id=${encodeURIComponent(message.account_id)}` : ''; - const url = `${API_BASE}/api/email/read/${encodeURIComponent(message.uid)}?folder=${encodeURIComponent(message.folder || 'INBOX')}${account}&mark_seen=false&full=true`; - const response = await fetch(url, { credentials: 'same-origin' }); - if (!response.ok) throw new Error(`Could not load "${message.subject || 'email'}" (HTTP ${response.status})`); - const data = await response.json(); - if (data?.error) throw new Error(data.error); - loaded[index] = { message, data }; + try { + const account = message.account_id ? `&account_id=${encodeURIComponent(message.account_id)}` : ''; + const url = `${API_BASE}/api/email/read/${encodeURIComponent(message.uid)}?folder=${encodeURIComponent(message.folder || 'INBOX')}${account}&mark_seen=false&full=true`; + const response = await fetch(url, { credentials: 'same-origin' }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = await response.json(); + if (data?.error) throw new Error(data.error); + loaded[index] = { ok: true, message, data }; + } catch (err) { + loaded[index] = { ok: false, message, error: err?.message || 'Failed to load' }; + } } }; await Promise.all(Array.from({ length: Math.min(4, messages.length) }, readWorker)); + const successful = loaded.filter(item => item && item.ok); + const omitted = loaded.filter(item => !item || !item.ok); + + if (successful.length === 0) { + throw new Error('None of the selected emails could be loaded for printing'); + } + // 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) { + if (omitted.length > 0) { + const count = omitted.length; + articles.push( + `` + ); + } + + for (const { message, data } of successful) { const subject = String(data.subject || message.subject || '(no subject)'); const from = data.from_name && data.from_address ? `${data.from_name} <${data.from_address}>` : data.from_name || data.from_address; 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); + + // Replace interactive image placeholder components with clean print-safe notices + safeBody.querySelectorAll('.email-inline-image-placeholder').forEach(ph => { + const title = ph.querySelector('.email-inline-image-title')?.textContent?.trim(); + const ariaLabel = ph.getAttribute('aria-label')?.trim(); + const label = title || ariaLabel || ''; + const isGeneric = !label || /^inline image$|^remote image$|^image unavailable$|^image$/i.test(label); + const span = safeBody.createElement('span'); + span.className = 'email-export-image-notice'; + span.textContent = isGeneric ? '[Image omitted]' : `[Image: ${label}]`; + ph.replaceWith(span); }); + + // Replace any remaining raw tags + safeBody.querySelectorAll('img').forEach(img => { + const alt = (img.getAttribute('alt') || img.getAttribute('title') || '').trim(); + const span = safeBody.createElement('span'); + span.className = 'email-export-image-notice'; + span.textContent = alt ? `[Image: ${alt}]` : '[Image omitted]'; + img.replaceWith(span); + }); + + // Strip interactive controls (buttons, inputs, actions) from the print document + safeBody.querySelectorAll('button, input, select, textarea, .email-inline-image-actions, .email-inline-image-btn').forEach(el => el.remove()); + safeBody.querySelectorAll('details').forEach(detail => { detail.open = true; }); const bodyHtml = safeBody.body.innerHTML; @@ -1192,11 +1230,14 @@ async function _printSelectedEmails(messages) { '.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}', + '.email-inline-image-placeholder,.email-inline-image-actions,.email-inline-image-btn{display:none !important}', + '.email-export-image-notice{display:inline-block;color:#666;font-style:italic;font-size:11px;margin:4px 0}', + '.email-export-pdf-omitted-notice{margin:0 0 16px;padding:8px 12px;background:#f8f9fa;border:1px solid #e2e8f0;border-radius:4px;font-size:11px;color:#64748b}', '@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' + const safeBaseName = successful.length === 1 + ? String(successful[0]?.data?.subject || successful[0]?.message?.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 @@ -1209,6 +1250,17 @@ async function _printSelectedEmails(messages) { 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); + + let cleanedUp = false; + const cleanupFrame = () => { + if (cleanedUp) return; + cleanedUp = true; + try { + frame.remove(); + } catch (_) {} + window.removeEventListener('afterprint', cleanupFrame); + }; + try { const printDoc = frame.contentDocument; printDoc.open(); @@ -1223,11 +1275,17 @@ async function _printSelectedEmails(messages) { container.innerHTML = articles.join(''); printDoc.body.appendChild(container); await printDoc.fonts.ready; - frame.contentWindow.addEventListener('afterprint', () => frame.remove(), { once: true }); + frame.contentWindow?.addEventListener('afterprint', cleanupFrame, { once: true }); + window.addEventListener('afterprint', cleanupFrame, { once: true }); + setTimeout(cleanupFrame, 60000); frame.contentWindow.focus(); frame.contentWindow.print(); + if (omitted.length > 0) { + const msg = `Printed ${successful.length} email${successful.length === 1 ? '' : 's'}; ${omitted.length} unavailable`; + showToast(msg); + } } catch (error) { - frame.remove(); + cleanupFrame(); throw error; } } diff --git a/tests/test_email_library_bulk_actions.py b/tests/test_email_library_bulk_actions.py index 6840b5916..1d8464270 100644 --- a/tests/test_email_library_bulk_actions.py +++ b/tests/test_email_library_bulk_actions.py @@ -411,3 +411,161 @@ def test_messages_download_bulk_enforces_account_ownership_guard(monkeypatch): assert res.status_code == 404 assert "Account not found" in res.text + + +def test_messages_download_bulk_mid_batch_failure_accounting(monkeypatch): + """When an exception occurs mid-batch after partial progress, already-exported + records must not be double-counted as skipped.""" + class MidBatchFailImap: + def select(self, folder, readonly=True): + return "OK", [b"1"] + def uid(self, command, *args): + records = [] + for i in range(1, 5): + records.append((f"{i} (UID {i} RFC822 {{{10}}}".encode(), f"Subject: Msg {i}\r\n\r\nBody {i}".encode())) + class BadRecord: + def __bytes__(self): + raise RuntimeError("Corrupt memory buffer") + records.append((b"5 (UID 5 RFC822 {10}", BadRecord())) + return "OK", records + + from contextlib import contextmanager + from fastapi import FastAPI + from fastapi.testclient import TestClient + from routes.email import email_routes as routes + + @contextmanager + def mock_imap(account_id=None, owner=""): + yield MidBatchFailImap() + + monkeypatch.setattr(routes, "_start_poller", lambda: None) + monkeypatch.setattr(routes, "_POOL_HOOKS", {}) + monkeypatch.setattr(routes, "_imap", mock_imap) + + app = FastAPI() + app.include_router(routes.setup_email_routes()) + app.dependency_overrides[routes.require_owner] = lambda: "test-user" + client = TestClient(app) + + rows = [{"uid": str(i)} for i in range(1, 11)] + res = client.post("/api/email/messages-download-bulk", json={"messages": rows}) + assert res.status_code == 200 + exp = int(res.headers.get("x-exported-emails")) + skp = int(res.headers.get("x-skipped-emails")) + assert exp == 4 + assert skp == 6 + assert exp + skp == 10 + + +def test_messages_download_bulk_size_cap_413_boundary(monkeypatch): + """Cumulative export size exceeding max_total_bytes returns HTTP 413.""" + class LargeMessageImap: + def select(self, folder, readonly=True): + return "OK", [b"1"] + def uid(self, command, *args): + raw_uids = args[0] if isinstance(args[0], str) else args[0].decode() + if "(UID RFC822.SIZE)" in str(args): + records = [] + for u in raw_uids.split(","): + records.append(f"{u} (UID {u} RFC822.SIZE 10485760)".encode()) + return "OK", records + records = [] + big_chunk = b"X" * (10 * 1024 * 1024) + for u in raw_uids.split(","): + records.append((f"{u} (UID {u} RFC822".encode(), big_chunk)) + return "OK", records + + from contextlib import contextmanager + from fastapi import FastAPI + from fastapi.testclient import TestClient + from routes.email import email_routes as routes + + @contextmanager + def mock_imap(account_id=None, owner=""): + yield LargeMessageImap() + + monkeypatch.setattr(routes, "_start_poller", lambda: None) + monkeypatch.setattr(routes, "_POOL_HOOKS", {}) + monkeypatch.setattr(routes, "_imap", mock_imap) + + app = FastAPI() + app.include_router(routes.setup_email_routes()) + app.dependency_overrides[routes.require_owner] = lambda: "test-user" + client = TestClient(app) + + # 52 messages of 10 MB = 520 MB > 512 MiB limit + payload_52 = [{"uid": str(i + 1), "subject": f"Big {i}"} for i in range(52)] + res = client.post("/api/email/messages-download-bulk", json={"messages": payload_52}) + assert res.status_code == 413 + assert res.json()["detail"] == "Export too large" + + +def test_messages_download_bulk_per_message_limit_413(monkeypatch): + """A single oversized message (>64 MiB) triggers HTTP 413.""" + class HugeSingleMsgImap: + def select(self, folder, readonly=True): + return "OK", [b"1"] + def uid(self, command, *args): + return "OK", [b"1 (UID 101 RFC822.SIZE 73400320)"] + + from contextlib import contextmanager + from fastapi import FastAPI + from fastapi.testclient import TestClient + from routes.email import email_routes as routes + + @contextmanager + def mock_imap(account_id=None, owner=""): + yield HugeSingleMsgImap() + + monkeypatch.setattr(routes, "_start_poller", lambda: None) + monkeypatch.setattr(routes, "_POOL_HOOKS", {}) + monkeypatch.setattr(routes, "_imap", mock_imap) + + app = FastAPI() + app.include_router(routes.setup_email_routes()) + app.dependency_overrides[routes.require_owner] = lambda: "test-user" + client = TestClient(app) + + res = client.post("/api/email/messages-download-bulk", json={ + "messages": [{"uid": "101", "subject": "Huge Email"}] + }) + assert res.status_code == 413 + assert res.json()["detail"] == "Export too large" + + +def test_messages_download_bulk_account_ownership_memoization(monkeypatch): + """Account ownership validation is memoized per distinct account_id to eliminate N+1 DB queries.""" + owns_calls = [] + def counting_assert_owns(account_id, owner): + owns_calls.append(account_id) + + raw_rfc822 = b"From: sender@example.com\r\nSubject: Test\r\n\r\nBody" + msgs = {str(i): raw_rfc822 for i in range(1, 26)} + mailbox = _MockImapConn(msgs) + client = _create_test_app(monkeypatch, mailbox=mailbox, assert_owns_fn=counting_assert_owns) + + # 25 messages all referencing the same account_id + payload = [{"uid": str(i), "account_id": "acc-shared"} for i in range(1, 26)] + res = client.post("/api/email/messages-download-bulk", json={"messages": payload}) + assert res.status_code == 200 + assert len(owns_calls) == 1 + + +def test_messages_download_bulk_corrupt_zip_fails_closed(monkeypatch): + """If ZIP construction fails (e.g. disk error), fail closed with 500 instead of returning corrupt archive.""" + import zipfile + raw_rfc822 = b"From: sender@example.com\r\nSubject: Test\r\n\r\nBody" + mailbox = _MockImapConn({"101": raw_rfc822, "102": raw_rfc822}) + + orig_writestr = zipfile.ZipFile.writestr + def failing_writestr(self, zinfo_or_arcname, data=None, *args, **kwargs): + raise OSError("Disk write failed: No space left on device") + + monkeypatch.setattr(zipfile.ZipFile, "writestr", failing_writestr) + client = _create_test_app(monkeypatch, mailbox=mailbox) + + res = client.post("/api/email/messages-download-bulk", json={ + "messages": [{"uid": "101"}, {"uid": "102"}] + }) + assert res.status_code == 500 + assert "Failed to create export archive" in res.json()["detail"] From 75df766358c2e6e04368c6416dbc8c5d1a500412 Mon Sep 17 00:00:00 2001 From: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:09:47 +0100 Subject: [PATCH 5/7] fix(email): harden RFC822.SIZE preflight validation and ignore duplicate IMAP records - Require valid RFC822.SIZE preflight metadata for every message before body FETCH - Fail safely with HTTP 502 without body fetching on NO, malformed, or inconsistent sizes - Distinguish missing mail (HTTP 404) from bad size preflight (HTTP 502) - Discard duplicate IMAP response records for already-processed UIDs in batch loop - Correct print notification toast to "Prepared N emails for printing; M unavailable" - Add dedicated Playwright browser regression test script to repository test suite - Add request-level tests for preflight NO, empty, partial, malformed, and duplicates --- routes/email/email_routes.py | 124 ++++++-- static/js/emailLibrary/index.js | 2 +- tests/test_email_library_bulk_actions.py | 206 ++++++++++++- tests/test_email_library_print_browser.cjs | 331 +++++++++++++++++++++ 4 files changed, 633 insertions(+), 30 deletions(-) create mode 100644 tests/test_email_library_print_browser.cjs diff --git a/routes/email/email_routes.py b/routes/email/email_routes.py index 541a7189f..8443df3c1 100644 --- a/routes/email/email_routes.py +++ b/routes/email/email_routes.py @@ -4312,20 +4312,37 @@ def setup_email_routes(): with _imap(account_id, owner=owner) as conn: conn.select(_q(folder), readonly=True) uid_b = uid.encode() + st_sz, sz_data = None, None try: st_sz, sz_data = _imap_uid_fetch(conn, uid_b, "(UID RFC822.SIZE)") - if st_sz == "OK" and sz_data: - for item in sz_data: - meta = item[0] if isinstance(item, tuple) else item - if isinstance(meta, bytes): - sz = _size_from_fetch_meta(meta) - if sz is not None and (sz > max_message_bytes or sz > max_total_bytes): - raise HTTPException(status_code=413, detail="Export too large") - except HTTPException: - raise except Exception as sz_err: - logger.debug("Single message size preflight error: %s", sz_err) + logger.warning("Single message size preflight query error for uid=%s: %s", uid, sz_err) + if st_sz != "OK": + logger.warning("Single message size preflight status=%s for uid=%s", st_sz, uid) + raise HTTPException(status_code=502, detail="Unable to verify email size safety") + + if not sz_data: + # Message does not exist in mailbox (missing mail) + raise HTTPException(status_code=404, detail="No selected emails could be exported") + + sz: int | None = None + for item in sz_data: + meta = item[0] if isinstance(item, tuple) else item + if isinstance(meta, bytes): + u = _uid_from_fetch_meta(meta) + if u == uid: + sz = _size_from_fetch_meta(meta) + break + + if sz is None or sz < 0: + logger.warning("Single message size preflight returned invalid/malformed size for uid=%s", uid) + raise HTTPException(status_code=502, detail="Invalid email size metadata from server") + + if sz > max_message_bytes or sz > max_total_bytes: + raise HTTPException(status_code=413, detail="Export too large") + + # Preflight size safety established; perform RFC822 fetch. st, msg_data = _imap_uid_fetch(conn, uid_b, "(UID RFC822)") if st == "OK" and msg_data: for meta, raw in _group_uid_fetch_records(msg_data): @@ -4407,7 +4424,14 @@ def setup_email_routes(): group = remote_groups.setdefault((account_id, folder), {}) group.setdefault(uid, (_email_filename(row, uid), row)) + preflight_error: HTTPException | None = None + # Fetch original RFC822 data in batches and store it byte-for-byte. + # Note on IMAP memory-safety: Python's standard imaplib buffers socket responses into + # memory before returning literals to application code. Preflight verification ensures + # we do not intentionally fetch messages exceeding limits, but an untrusted or non-compliant + # IMAP server sending unexpectedly large literals cannot be bounded at the transport level + # without custom socket streaming. Post-fetch checks guard application-level storage. for (account_id, folder), rows_by_uid in remote_groups.items(): rows = list(rows_by_uid.items()) remaining_rows = len(rows) @@ -4417,26 +4441,64 @@ def setup_email_routes(): # Size preflight query to catch oversized exports early and bound batch sizes. uid_set_all = ",".join(uid for uid, _ in rows).encode() - size_by_uid: dict[str, int] = {} + st_sz, sz_data = None, None try: st_sz, sz_data = _imap_uid_fetch(conn, uid_set_all, "(UID RFC822.SIZE)") - if st_sz == "OK" and sz_data: - for item in sz_data: - meta = item[0] if isinstance(item, tuple) else item - if isinstance(meta, bytes): - u = _uid_from_fetch_meta(meta) - s = _size_from_fetch_meta(meta) - if u and s is not None: - size_by_uid[u] = s except Exception as sz_err: - logger.debug("IMAP size preflight query failed or unsupported: %s", sz_err) + logger.warning("IMAP size preflight query failed for folder=%s: %s", folder, sz_err) - if size_by_uid: - for u, s in size_by_uid.items(): - if s > max_message_bytes: - raise HTTPException(status_code=413, detail="Export too large") - if total_bytes + sum(size_by_uid.values()) > max_total_bytes: + if st_sz != "OK": + logger.warning("IMAP size preflight status=%s for folder=%s account=%s", st_sz, folder, account_id) + preflight_error = HTTPException(status_code=502, detail="Unable to verify email size safety") + skipped += remaining_rows + remaining_rows = 0 + continue + + if not sz_data: + # All requested UIDs in this mailbox are missing on server + skipped += remaining_rows + remaining_rows = 0 + continue + + reported_sizes: dict[str, int | None] = {} + for item in sz_data: + meta = item[0] if isinstance(item, tuple) else item + if isinstance(meta, bytes): + u = _uid_from_fetch_meta(meta) + if u: + reported_sizes[u] = _size_from_fetch_meta(meta) + + # Categorize requested UIDs + malformed_uids = [ + uid for uid, _ in rows + if uid in reported_sizes and (reported_sizes[uid] is None or reported_sizes[uid] < 0) + ] + if malformed_uids: + logger.warning("IMAP size preflight returned invalid/malformed size for uids=%s in folder=%s", malformed_uids, folder) + preflight_error = HTTPException(status_code=502, detail="Invalid email size metadata from server") + skipped += remaining_rows + remaining_rows = 0 + continue + + # Identify missing mail (requested UIDs not returned in preflight) + missing_uids = {uid for uid, _ in rows if uid not in reported_sizes} + if missing_uids: + skipped += len(missing_uids) + remaining_rows -= len(missing_uids) + rows = [item for item in rows if item[0] not in missing_uids] + + if not rows: + continue + + size_by_uid: dict[str, int] = { + uid: reported_sizes[uid] for uid, _ in rows + } + + for u, s in size_by_uid.items(): + if s > max_message_bytes: raise HTTPException(status_code=413, detail="Export too large") + if total_bytes + sum(size_by_uid.values()) > max_total_bytes: + raise HTTPException(status_code=413, detail="Export too large") # Form batches with at most 20 messages and capped at 10 MiB payload. batches: list[list[tuple[str, tuple[str, dict]]]] = [] @@ -4445,10 +4507,10 @@ def setup_email_routes(): max_batch_bytes = 10 * 1024 * 1024 for item in rows: uid_val = item[0] - sz = size_by_uid.get(uid_val, 0) + sz = size_by_uid[uid_val] if cur_batch and ( len(cur_batch) >= 20 or - (sz > 0 and cur_batch_bytes + sz > max_batch_bytes) + (cur_batch_bytes + sz > max_batch_bytes) ): batches.append(cur_batch) cur_batch = [] @@ -4469,7 +4531,11 @@ def setup_email_routes(): found: set[str] = set() for meta, raw_message in _group_uid_fetch_records(msg_data): uid = _uid_from_fetch_meta(meta) - if uid not in wanted or not raw_message: + if not uid or uid not in wanted or not raw_message: + continue + if uid in found: + # P2: Reject duplicate IMAP response records for an already-processed UID + logger.debug("Ignoring duplicate IMAP FETCH record for uid=%s in folder=%s", uid, folder) continue filename = wanted[uid] unique_name = filename @@ -4502,6 +4568,8 @@ def setup_email_routes(): logger.warning("Bulk email export skipped mailbox folder=%s account=%s count=%s: %s", folder, account_id, remaining_rows, exc) if exported <= 0: + if preflight_error is not None: + raise preflight_error raise HTTPException(status_code=404, detail="No selected emails could be exported") spooled_file.seek(0) diff --git a/static/js/emailLibrary/index.js b/static/js/emailLibrary/index.js index cc08f2d0c..832549f31 100644 --- a/static/js/emailLibrary/index.js +++ b/static/js/emailLibrary/index.js @@ -1281,7 +1281,7 @@ async function _printSelectedEmails(messages) { frame.contentWindow.focus(); frame.contentWindow.print(); if (omitted.length > 0) { - const msg = `Printed ${successful.length} email${successful.length === 1 ? '' : 's'}; ${omitted.length} unavailable`; + const msg = `Prepared ${successful.length} email${successful.length === 1 ? '' : 's'} for printing; ${omitted.length} unavailable`; showToast(msg); } } catch (error) { diff --git a/tests/test_email_library_bulk_actions.py b/tests/test_email_library_bulk_actions.py index 1d8464270..362749044 100644 --- a/tests/test_email_library_bulk_actions.py +++ b/tests/test_email_library_bulk_actions.py @@ -264,21 +264,33 @@ def test_email_compose_can_attach_gallery_images(): class _MockImapConn: - def __init__(self, messages_by_uid: dict[str, bytes] | None = None, fetch_exc: Exception | None = None): + def __init__(self, messages_by_uid: dict[str, bytes] | None = None, fetch_exc: Exception | None = None, fetch_cb=None): self.messages = messages_by_uid or {} self.fetch_exc = fetch_exc + self.fetch_cb = fetch_cb + self.fetch_calls: list = [] def select(self, folder, readonly=True): return "OK", [b"1"] def uid(self, command, *args): if command == "FETCH": + self.fetch_calls.append(args) if self.fetch_exc is not None: raise self.fetch_exc + if self.fetch_cb: + return self.fetch_cb(args) uid_set_b = args[0] uid_set = uid_set_b.decode() if isinstance(uid_set_b, bytes) else str(uid_set_b) uids = [u.strip() for u in uid_set.split(",") if u.strip()] + fetch_cmd = str(args[1]) if len(args) > 1 else "" res = [] + if "RFC822.SIZE" in fetch_cmd: + for u in uids: + if u in self.messages: + meta = f"1 (UID {u} RFC822.SIZE {len(self.messages[u])})".encode() + res.append(meta) + return "OK", res for u in uids: if u in self.messages: meta = f"1 (UID {u} RFC822 {{{len(self.messages[u])}}}".encode() @@ -420,6 +432,9 @@ def test_messages_download_bulk_mid_batch_failure_accounting(monkeypatch): def select(self, folder, readonly=True): return "OK", [b"1"] def uid(self, command, *args): + fetch_cmd = str(args[1]) if len(args) > 1 else "" + if "RFC822.SIZE" in fetch_cmd: + return "OK", [f"{i} (UID {i} RFC822.SIZE 100)".encode() for i in range(1, 11)] records = [] for i in range(1, 5): records.append((f"{i} (UID {i} RFC822 {{{10}}}".encode(), f"Subject: Msg {i}\r\n\r\nBody {i}".encode())) @@ -569,3 +584,192 @@ def test_messages_download_bulk_corrupt_zip_fails_closed(monkeypatch): }) assert res.status_code == 500 assert "Failed to create export archive" in res.json()["detail"] + + +def test_messages_download_bulk_preflight_no_fails_safe_no_body_fetch(monkeypatch): + """When preflight returns NO, size safety is unknown: fail safely (502) and do not fetch bodies.""" + mailbox = _MockImapConn({"101": b"dummy", "102": b"dummy"}) + mailbox.fetch_cb = lambda args: ("NO", []) if "RFC822.SIZE" in str(args) else ("OK", []) + client = _create_test_app(monkeypatch, mailbox=mailbox) + + res = client.post("/api/email/messages-download-bulk", json={"messages": [{"uid": "101"}, {"uid": "102"}]}) + assert res.status_code == 502 + assert "Unable to verify email size safety" in res.json()["detail"] + assert not any("(UID RFC822)" in str(call) for call in mailbox.fetch_calls) + + +def test_messages_download_bulk_preflight_empty_missing_mail(monkeypatch): + """When preflight returns empty data, all requested messages are missing on server: returns 404 with no body fetch.""" + mailbox = _MockImapConn() # no messages + client = _create_test_app(monkeypatch, mailbox=mailbox) + + res = client.post("/api/email/messages-download-bulk", json={"messages": [{"uid": "101"}, {"uid": "102"}]}) + assert res.status_code == 404 + assert "No selected emails could be exported" in res.json()["detail"] + assert not any("(UID RFC822)" in str(call) for call in mailbox.fetch_calls) + + +def test_messages_download_bulk_preflight_malformed_fails_safe_no_body_fetch(monkeypatch): + """When preflight returns records without RFC822.SIZE, fail safely (502) and do not fetch bodies.""" + mailbox = _MockImapConn({"101": b"dummy"}) + mailbox.fetch_cb = lambda args: ("OK", [b"1 (UID 101 FLAGS (\\Seen))"]) if "RFC822.SIZE" in str(args) else ("OK", []) + client = _create_test_app(monkeypatch, mailbox=mailbox) + + res = client.post("/api/email/messages-download-bulk", json={"messages": [{"uid": "101"}]}) + assert res.status_code == 502 + assert "Invalid email size metadata from server" in res.json()["detail"] + assert not any("(UID RFC822)" in str(call) for call in mailbox.fetch_calls) + + +def test_messages_download_bulk_preflight_inconsistent_fails_safe_no_body_fetch(monkeypatch): + """When preflight returns negative or corrupted size values, fail safely (502) and do not fetch bodies.""" + mailbox = _MockImapConn({"101": b"dummy"}) + mailbox.fetch_cb = lambda args: ("OK", [b"1 (UID 101 RFC822.SIZE -50)"]) if "RFC822.SIZE" in str(args) else ("OK", []) + client = _create_test_app(monkeypatch, mailbox=mailbox) + + res = client.post("/api/email/messages-download-bulk", json={"messages": [{"uid": "101"}]}) + assert res.status_code == 502 + assert "Invalid email size metadata from server" in res.json()["detail"] + assert not any("(UID RFC822)" in str(call) for call in mailbox.fetch_calls) + + +def test_messages_download_bulk_preflight_partial_missing_omitted_from_body_fetch(monkeypatch): + """When some requested UIDs do not exist on the server, omit them from body fetch and export verified items.""" + msg1 = b"From: a@example.com\r\nSubject: Only One\r\n\r\nBody" + mailbox = _MockImapConn({"101": msg1}) # 102 does not exist on server + client = _create_test_app(monkeypatch, mailbox=mailbox) + + res = client.post("/api/email/messages-download-bulk", json={ + "messages": [{"uid": "101"}, {"uid": "102"}] + }) + assert res.status_code == 200 + assert res.headers["x-exported-emails"] == "1" + assert res.headers["x-skipped-emails"] == "1" + + # Verify body fetch was only requested for 101, NOT 102 + body_fetches = [call for call in mailbox.fetch_calls if "(UID RFC822)" in str(call)] + assert len(body_fetches) == 1 + requested_uids = body_fetches[0][0].decode() if isinstance(body_fetches[0][0], bytes) else str(body_fetches[0][0]) + assert requested_uids == "101" + assert "102" not in requested_uids + + +def test_messages_download_bulk_duplicate_imap_records_rejected(monkeypatch): + """Duplicate IMAP FETCH response records for the same UID must be ignored. + They must not produce duplicate archive entries or corrupt accounting.""" + import io + import zipfile + + msg1 = b"From: a@example.com\r\nSubject: Unique\r\n\r\nBody 1" + mailbox = _MockImapConn({"101": msg1}) + + def custom_fetch(args): + fetch_cmd = str(args[1]) if len(args) > 1 else "" + if "RFC822.SIZE" in fetch_cmd: + return "OK", [b"1 (UID 101 RFC822.SIZE 100)", b"2 (UID 102 RFC822.SIZE 100)"] + # In body fetch, server sends duplicate records for 101, and nothing for 102 + records = [ + (b"1 (UID 101 RFC822 {50}", msg1), + b")", + (b"1 (UID 101 RFC822 {50}", msg1), # DUPLICATE RECORD + b")", + ] + return "OK", records + + mailbox.fetch_cb = custom_fetch + client = _create_test_app(monkeypatch, mailbox=mailbox) + + res = client.post("/api/email/messages-download-bulk", json={ + "messages": [{"uid": "101"}, {"uid": "102"}] + }) + assert res.status_code == 200 + assert res.headers["x-exported-emails"] == "1" + assert res.headers["x-skipped-emails"] == "1" + + zf = zipfile.ZipFile(io.BytesIO(res.content)) + assert len(zf.namelist()) == 1 + assert "101.eml" in zf.namelist()[0] + + +def test_messages_download_bulk_single_message_preflight_failure_no_body_fetch(monkeypatch): + """In single message mode, bad preflight or missing mail fails safely without body fetch.""" + mailbox = _MockImapConn({"101": b"dummy"}) + + # Case A: preflight NO -> 502 + mailbox.fetch_cb = lambda args: ("NO", []) if "RFC822.SIZE" in str(args) else ("OK", []) + client = _create_test_app(monkeypatch, mailbox=mailbox) + res_no = client.post("/api/email/messages-download-bulk", json={"messages": [{"uid": "101"}]}) + assert res_no.status_code == 502 + assert not any("(UID RFC822)" in str(call) for call in mailbox.fetch_calls) + + # Case B: missing mail -> 404 + mailbox.fetch_calls.clear() + mailbox.fetch_cb = lambda args: ("OK", []) if "RFC822.SIZE" in str(args) else ("OK", []) + res_miss = client.post("/api/email/messages-download-bulk", json={"messages": [{"uid": "101"}]}) + assert res_miss.status_code == 404 + assert not any("(UID RFC822)" in str(call) for call in mailbox.fetch_calls) + + # Case C: malformed metadata -> 502 + mailbox.fetch_calls.clear() + mailbox.fetch_cb = lambda args: ("OK", [b"1 (UID 101 NO_SIZE)"]) if "RFC822.SIZE" in str(args) else ("OK", []) + res_mal = client.post("/api/email/messages-download-bulk", json={"messages": [{"uid": "101"}]}) + assert res_mal.status_code == 502 + assert not any("(UID RFC822)" in str(call) for call in mailbox.fetch_calls) + + +def test_messages_download_bulk_spooled_stream_lifecycle(): + """Verify that _iter_spooled closes the underlying SpooledTemporaryFile on completion and GeneratorExit.""" + import tempfile + from routes.email.email_routes import setup_email_routes + + # Test normal completion + f = tempfile.SpooledTemporaryFile(max_size=1024, mode="w+b") + f.write(b"Hello world payload") + f.seek(0) + # Re-create generator helper pattern + def _iter(file_obj, chunk_size=64): + try: + while True: + chunk = file_obj.read(chunk_size) + if not chunk: + break + yield chunk + finally: + file_obj.close() + + gen = _iter(f) + chunks = list(gen) + assert b"".join(chunks) == b"Hello world payload" + assert f.closed + + # Test early disconnect / cancellation (GeneratorExit) + f2 = tempfile.SpooledTemporaryFile(max_size=1024, mode="w+b") + f2.write(b"Chunk1Chunk2Chunk3") + f2.seek(0) + gen2 = _iter(f2, chunk_size=6) + next(gen2) + assert not f2.closed + gen2.close() # Simulates client disconnect / generator cancellation + assert f2.closed + + +def test_email_print_browser_behaviors(): + """Real Chromium DOM: placeholder stripping, button removal, and partial failure.""" + import shutil + import subprocess + if not shutil.which('node'): + import pytest + pytest.skip('Node is required') + probe = subprocess.run(['node', '-e', "require.resolve('playwright')"], cwd=_REPO, capture_output=True) + if probe.returncode: + import pytest + pytest.skip('Playwright is required') + result = subprocess.run( + ['node', str(_REPO / 'tests/test_email_library_print_browser.cjs')], + cwd=_REPO, + capture_output=True, + text=True, + timeout=60, + ) + assert result.returncode == 0, result.stdout + result.stderr + assert "ALL_BROWSER_TESTS_PASSED" in result.stdout diff --git a/tests/test_email_library_print_browser.cjs b/tests/test_email_library_print_browser.cjs new file mode 100644 index 000000000..62ca7a108 --- /dev/null +++ b/tests/test_email_library_print_browser.cjs @@ -0,0 +1,331 @@ +const http = require('http'); +const fs = require('fs'); +const path = require('path'); +const { chromium } = require('playwright'); + +const ROOT = path.resolve(__dirname, '..'); + +// Find available chromium executable or default to Playwright's cached browser +function findChromiumPath() { + const candidates = [ + '/home/alteixeira20/.cache/ms-playwright/chromium_headless_shell-1248/chrome-headless-shell-linux64/chrome-headless-shell', + '/home/alteixeira20/.cache/ms-playwright/chromium-1248/chrome-linux/chrome', + ]; + for (const c of candidates) { + if (fs.existsSync(c)) return c; + } + return undefined; +} + +async function run() { + const server = http.createServer((req, res) => { + const url = new URL(req.url, 'http://127.0.0.1'); + if (url.pathname.startsWith('/api/email/read/')) { + const uid = url.pathname.replace('/api/email/read/', ''); + if (uid === 'fail_uid' || uid === 'net_fail') { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Mailbox disconnected' })); + return; + } + if (uid === 'crash_network') { + req.destroy(); + return; + } + + const markSeen = url.searchParams.get('mark_seen'); + if (markSeen !== 'false') { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'mark_seen must be false' })); + return; + } + + let body_html = ''; + if (uid === 'img_test') { + body_html = [ + '
', + '

Email with diverse images

', + '', + 'Company Brand Logo', + '', + '', + '', + '', + '', + '', + '', + '', + '
' + ].join('\n'); + } else if (uid === 'long_msg') { + body_html = `

${'A'.repeat(5000)}

`; + } else { + body_html = `

Standard message body for ${uid}

`; + } + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + uid: uid, + subject: `Subject ${uid}`, + from_name: 'Sender', + from_address: 'sender@example.com', + to: 'user@example.com', + date: '2026-10-20', + date_display: 'Oct 20, 2026', + body: `Plain text for ${uid}`, + body_html: body_html, + })); + return; + } + + let filePath = path.join(ROOT, url.pathname); + if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) { + const ext = path.extname(filePath); + const mime = ext === '.js' ? 'application/javascript' : ext === '.html' ? 'text/html' : ext === '.css' ? 'text/css' : 'text/plain'; + res.writeHead(200, { 'Content-Type': mime }); + res.end(fs.readFileSync(filePath)); + return; + } + res.writeHead(404); + res.end('Not found'); + }); + + await new Promise(r => server.listen(0, '127.0.0.1', r)); + const port = server.address().port; + const origin = `http://127.0.0.1:${port}`; + + const launchOpts = { headless: true }; + const chromePath = findChromiumPath(); + if (chromePath) launchOpts.executablePath = chromePath; + + const browser = await chromium.launch(launchOpts); + const context = await browser.newContext(); + const page = await context.newPage(); + + const results = {}; + + try { + await page.goto(`${origin}/static/index.html`); + await page.evaluate(() => { + if (!document.getElementById('email-lib-bulk-actions')) { + const d = document.createElement('div'); d.id = 'email-lib-bulk-actions'; document.body.appendChild(d); + } + if (!document.getElementById('email-lib-selected-count')) { + const d = document.createElement('div'); d.id = 'email-lib-selected-count'; document.body.appendChild(d); + } + }); + + // TEST 1: Full Print Image Sanitization and Replacement + const printImageResult = await page.evaluate(async (base) => { + window.API_BASE = base; + const mod = await import(`${base}/static/js/emailLibrary/index.js`); + const stateMod = await import(`${base}/static/js/emailLibrary/state.js`); + + stateMod.state._selectedUids = new Set(['img_test']); + stateMod.state._libEmails = [{ uid: 'img_test', subject: 'Image Test', folder: 'INBOX' }]; + + let iframePrintInvoked = false; + let printDocHtml = ''; + let afterPrintTriggered = false; + let hasPlaceholderElement = false; + + const observer = new MutationObserver(() => { + const frame = document.getElementById('email-browser-print-frame'); + if (frame && frame.contentWindow && !frame._patched) { + frame._patched = true; + frame.contentWindow.print = function() { + iframePrintInvoked = true; + printDocHtml = frame.contentDocument ? frame.contentDocument.documentElement.outerHTML : ''; + const body = frame.contentDocument ? frame.contentDocument.body : null; + hasPlaceholderElement = !!body?.querySelector('.email-inline-image-placeholder'); + frame.contentWindow.dispatchEvent(new Event('afterprint')); + afterPrintTriggered = true; + }; + } + }); + observer.observe(document.body, { childList: true }); + + await mod._exportSelectedEmailPdf(); + observer.disconnect(); + + const frameAfter = document.getElementById('email-browser-print-frame'); + + return { + iframePrintInvoked, + afterPrintTriggered, + frameRemovedAfterPrint: frameAfter === null, + hasLogoNotice: printDocHtml.includes('[Image: Company Brand Logo]'), + hasTrackerNotice: printDocHtml.includes('[Image: Tracking Pixel]'), + hasNoAltNotice: printDocHtml.includes('[Image omitted]'), + hasPlaceholderElement, + hasRemoteImageBlockedText: printDocHtml.includes('Remote image blocked'), + hasDownloadButton: printDocHtml.includes('email-inline-image-download-btn'), + hasInteractiveActionBtn: printDocHtml.includes('btn-action'), + hasXSS: printDocHtml.includes('