diff --git a/routes/email/email_routes.py b/routes/email/email_routes.py index 172790a6c..81173f889 100644 --- a/routes/email/email_routes.py +++ b/routes/email/email_routes.py @@ -25,13 +25,16 @@ import json import re import html import io +import tempfile 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 @@ -636,10 +639,15 @@ def _message_id_chain(*values: str) -> list[str]: def _uid_from_fetch_meta(meta_b: bytes) -> str: - m = re.search(rb"\bUID\s+(\d+)\b", meta_b) + m = re.search(rb"\bUID\s+(\d+)\b", meta_b, re.IGNORECASE) 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. @@ -4185,6 +4193,414 @@ 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 "") + + # 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 + validated_accounts: set[str] = set() + + 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) + + # 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) + + 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"): + 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) + uid_b = uid.encode() + st_sz, sz_data = None, None + try: + st_sz, sz_data = _imap_uid_fetch(conn, uid_b, "(UID RFC822.SIZE)") + except Exception as 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): + if _uid_from_fetch_meta(meta) == uid and raw: + message_bytes = bytes(raw) + break + except HTTPException: + raise + except Exception as 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") + + 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)) + + 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) + 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() + st_sz, sz_data = None, None + try: + st_sz, sz_data = _imap_uid_fetch(conn, uid_set_all, "(UID RFC822.SIZE)") + except Exception as sz_err: + logger.warning("IMAP size preflight query failed for folder=%s: %s", folder, sz_err) + + 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]]]] = [] + 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[uid_val] + if cur_batch and ( + len(cur_batch) >= 20 or + (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 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 + 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 preflight_error is not None: + raise preflight_error + + 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), + }, + ) + finally: + if not response_started: + spooled_file.close() + @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 2cba1edd6..afb21d76c 100644 --- a/static/js/emailLibrary/index.js +++ b/static/js/emailLibrary/index.js @@ -1123,7 +1123,175 @@ function _filenameFromContentDisposition(header) { return bare ? bare[1].trim() : ''; } -export async function _exportSelectedAttachments() { +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; + const readWorker = async () => { + while (nextIndex < messages.length) { + const index = nextIndex++; + const message = messages[index]; + 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 = []; + if (omitted.length > 0) { + const count = omitted.length; + articles.push( + `
Note: ${count} selected email${count === 1 ? ' was' : 's were'} unavailable and omitted from this print.
` + ); + } + + 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'); + + // 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; + + const fieldParts = []; + for (const [lbl, val] of [['From', from], ['To', data.to], ['Cc', data.cc], ['Date', data.date_display || data.date || message.date]]) { + if (!val) continue; + const esc = (s) => String(s).replace(/&/g, '&').replace(//g, '>'); + fieldParts.push(`
${esc(val)}
`); + } + + const esc = (s) => String(s).replace(/&/g, '&').replace(//g, '>'); + articles.push( + `
` + + `

${esc(subject)}

` + + `` + + `` + + `
` + ); + } + + 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}', + '.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 = 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 + // 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); + + 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(); + 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', cleanupFrame, { once: true }); + window.addEventListener('afterprint', cleanupFrame, { once: true }); + setTimeout(cleanupFrame, 60000); + frame.contentWindow.focus(); + frame.contentWindow.print(); + if (omitted.length > 0) { + const msg = `Prepared ${successful.length} email${successful.length === 1 ? '' : 's'} for printing; ${omitted.length} unavailable`; + showToast(msg); + } + } catch (error) { + cleanupFrame(); + throw error; + } +} + +async function _exportSelectedBundle(kind) { const messages = _selectedEmailExportPayload(); if (!messages.length) { showToast('Select emails first'); @@ -1151,8 +1319,14 @@ 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 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`, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, @@ -1170,12 +1344,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' : kind === 'print' ? 'Could not open browser print' : 'Failed to export attachments')); } finally { if (busySpinner) busySpinner.destroy(); if (actionsBtn) { @@ -1191,6 +1374,18 @@ export async function _exportSelectedAttachments() { } } +export async function _exportSelectedEmails() { + return _exportSelectedBundle('emails'); +} + +export async function _exportSelectedEmailPdf() { + return _exportSelectedBundle('print'); +} + +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..57f7284e1 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: 'Print / save 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..fc46399c5 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,96 @@ 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_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() + print_src = _function_source("_printSelectedEmails") + menu_src = js_function_source("_showBulkActionsMenu", frontend) + + 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 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") @@ -171,3 +261,808 @@ 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, 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() + 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 + + +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): + 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())) + 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"] + + +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_uid_and_size_from_fetch_meta_case_insensitivity_and_validation(): + """Unit test: _uid_from_fetch_meta and _size_from_fetch_meta case insensitivity and boundary validation.""" + from routes.email.email_routes import _uid_from_fetch_meta, _size_from_fetch_meta + + # 1. Standard uppercase UID and RFC822.SIZE + meta_upper = b"1 (UID 101 RFC822.SIZE 1234)" + assert _uid_from_fetch_meta(meta_upper) == "101" + assert _size_from_fetch_meta(meta_upper) == 1234 + + # 2. Lowercase uid and rfc822.size + meta_lower = b"1 (uid 202 rfc822.size 5678)" + assert _uid_from_fetch_meta(meta_lower) == "202" + assert _size_from_fetch_meta(meta_lower) == 5678 + + # 3. Mixed-case combinations + combos = [ + (b"1 (Uid 303 Rfc822.Size 1000)", "303", 1000), + (b"1 (uID 404 RFC822.size 2000)", "404", 2000), + (b"1 (UID 505 rfc822.size 3000)", "505", 3000), + (b"1 (uid 606 RFC822.SIZE 4000)", "606", 4000), + (b"1 (uId 707 Rfc822.SIZE 5000)", "707", 5000), + (b"1 (FLAGS (\\Seen) uid 808 rfc822.Size 6000)", "808", 6000), + ] + for meta_bytes, exp_uid, exp_sz in combos: + assert _uid_from_fetch_meta(meta_bytes) == exp_uid + assert _size_from_fetch_meta(meta_bytes) == exp_sz + + # 4. Malformed metadata cases + assert _uid_from_fetch_meta(b"1 (rfc822.size 1234)") == "" + assert _size_from_fetch_meta(b"1 (uid 101 flags (\\seen))") is None + assert _uid_from_fetch_meta(b"1 (uid abc rfc822.size 1234)") == "" + assert _size_from_fetch_meta(b"1 (uid 101 rfc822.size invalid)") is None + assert _size_from_fetch_meta(b"1 (uid 101 rfc822.size -50)") is None + + # 5. Boundary integrity (must not match substrings like UUID, GUID, NOT_RFC822.SIZE) + assert _uid_from_fetch_meta(b"1 (UUID 101 GUID 202 NOT_RFC822.SIZE 1234)") == "" + assert _size_from_fetch_meta(b"1 (UUID 101 GUID 202 NOT_RFC822.SIZE 1234)") is None + assert _uid_from_fetch_meta(b"1 (uid101 rfc822.size1234)") == "" + assert _size_from_fetch_meta(b"1 (uid101 rfc822.size1234)") is None + assert _uid_from_fetch_meta(b"") == "" + assert _size_from_fetch_meta(b"") is None + + +def test_messages_download_bulk_case_insensitive_imap_atoms(monkeypatch): + """End-to-end: Bulk and single-message export with lowercase and mixed-case IMAP atoms.""" + import io + import zipfile + + msg101 = b"From: a@example.com\r\nSubject: Item 101\r\n\r\nBody 1" + msg102 = b"From: b@example.com\r\nSubject: Item 102\r\n\r\nBody 2" + mailbox = _MockImapConn({"101": msg101, "102": msg102}) + + def custom_fetch(args): + fetch_cmd = str(args[1]) if len(args) > 1 else "" + if "RFC822.SIZE" in fetch_cmd or "rfc822.size" in fetch_cmd.lower(): + # Server returns lowercase/mixed-case atoms + return "OK", [ + b"1 (uid 101 rfc822.size " + str(len(msg101)).encode() + b")", + b"2 (Uid 102 Rfc822.Size " + str(len(msg102)).encode() + b")", + ] + # In body fetch, server returns mixed-case atoms + return "OK", [ + (b"1 (uid 101 rfc822 {" + str(len(msg101)).encode(), msg101), + b")", + (b"2 (Uid 102 RFC822 {" + str(len(msg102)).encode(), msg102), + b")", + ] + + mailbox.fetch_cb = custom_fetch + client = _create_test_app(monkeypatch, mailbox=mailbox) + + # 1. Bulk download (2 items) with lowercase / mixed-case IMAP responses + res_bulk = client.post("/api/email/messages-download-bulk", json={ + "messages": [{"uid": "101"}, {"uid": "102"}] + }) + assert res_bulk.status_code == 200 + assert res_bulk.headers["x-exported-emails"] == "2" + assert res_bulk.headers["x-skipped-emails"] == "0" + zf = zipfile.ZipFile(io.BytesIO(res_bulk.content)) + assert len(zf.namelist()) == 2 + + # 2. Single message download fast path with lowercase IMAP response + mailbox.fetch_calls.clear() + res_single = client.post("/api/email/messages-download-bulk", json={ + "messages": [{"uid": "101"}] + }) + assert res_single.status_code == 200 + assert res_single.headers["content-type"].startswith("message/rfc822") + assert res_single.content == msg101 + + # 3. Lowercase malformed preflight fails safely with 502 and NO body fetch + mailbox.fetch_calls.clear() + mailbox.fetch_cb = lambda args: ( + ("OK", [b"1 (uid 101 rfc822.size NaN)"]) + if "RFC822.SIZE" in str(args) or "rfc822.size" in str(args).lower() + else ("OK", []) + ) + res_malformed = client.post("/api/email/messages-download-bulk", json={ + "messages": [{"uid": "101"}] + }) + assert res_malformed.status_code == 502 + assert "Invalid email size metadata from server" in res_malformed.json()["detail"] + assert not any("(UID RFC822)" in str(call) for call in mailbox.fetch_calls) + + +def test_messages_download_bulk_multi_group_preflight_failure_propagates_502(monkeypatch): + """A preflight safety failure in any group produces HTTP 502 for the entire request, + even when another group has already exported messages into the temporary ZIP. + Confirms temporary-file cleanup, zero body FETCH for the failed group, and both group orderings.""" + import tempfile + + msg101 = b"From: a@example.com\r\nSubject: Good\r\n\r\nBody 1" + + spooled_instances: list[tempfile.SpooledTemporaryFile] = [] + orig_spooled = tempfile.SpooledTemporaryFile + + def tracking_spooled(*args, **kwargs): + inst = orig_spooled(*args, **kwargs) + spooled_instances.append(inst) + return inst + + monkeypatch.setattr(tempfile, "SpooledTemporaryFile", tracking_spooled) + + class MultiMailboxConn: + def __init__(self): + self.fetch_calls: list[tuple[str, tuple]] = [] + self.current_folder = "INBOX" + + def select(self, folder, readonly=True): + self.current_folder = str(folder) + return "OK", [b"1"] + + def uid(self, command, *args): + self.fetch_calls.append((self.current_folder, args)) + fetch_cmd = str(args[1]) if len(args) > 1 else "" + uid_set = args[0].decode() if isinstance(args[0], bytes) else str(args[0]) + + if "RFC822.SIZE" in fetch_cmd: + if "folder-fail" in self.current_folder: + return "NO", [] # Preflight failure for folder-fail + return "OK", [f"1 (UID {uid_set} RFC822.SIZE 100)".encode()] + + # Body fetch + if "folder-fail" in self.current_folder: + return "NO", [] + return "OK", [(f"1 (UID {uid_set} RFC822 {{100}}".encode(), msg101), b")"] + + # 1. Successful group followed by failed preflight + conn1 = MultiMailboxConn() + client1 = _create_test_app(monkeypatch, mailbox=conn1) + spooled_instances.clear() + + res1 = client1.post("/api/email/messages-download-bulk", json={ + "messages": [ + {"uid": "101", "folder": "folder-good"}, + {"uid": "202", "folder": "folder-fail"}, + ] + }) + assert res1.status_code == 502 + assert "Unable to verify email size safety" in res1.json()["detail"] + + # No RFC822 body FETCH for the failed group + failed_body_fetches1 = [ + call for folder, call in conn1.fetch_calls + if "folder-fail" in folder and "(UID RFC822)" in str(call) + ] + assert len(failed_body_fetches1) == 0 + + # Temporary file cleanup confirmed + assert len(spooled_instances) == 1 + assert spooled_instances[0].closed + + # 2. Failed preflight followed by successful group + conn2 = MultiMailboxConn() + client2 = _create_test_app(monkeypatch, mailbox=conn2) + spooled_instances.clear() + + res2 = client2.post("/api/email/messages-download-bulk", json={ + "messages": [ + {"uid": "202", "folder": "folder-fail"}, + {"uid": "101", "folder": "folder-good"}, + ] + }) + assert res2.status_code == 502 + assert "Unable to verify email size safety" in res2.json()["detail"] + + # No RFC822 body FETCH for the failed group + failed_body_fetches2 = [ + call for folder, call in conn2.fetch_calls + if "folder-fail" in folder and "(UID RFC822)" in str(call) + ] + assert len(failed_body_fetches2) == 0 + + # Temporary file cleanup confirmed + assert len(spooled_instances) == 1 + assert spooled_instances[0].closed + + +def test_messages_download_bulk_multi_group_both_successful(monkeypatch): + """When both groups succeed preflight and body fetch, return 200 with all messages in ZIP.""" + import io + import zipfile + + msg101 = b"From: a@example.com\r\nSubject: Item 1\r\n\r\nBody 1" + msg202 = b"From: b@example.com\r\nSubject: Item 2\r\n\r\nBody 2" + + class MultiMailboxConn: + def __init__(self): + self.current_folder = "INBOX" + + def select(self, folder, readonly=True): + self.current_folder = str(folder) + return "OK", [b"1"] + + def uid(self, command, *args): + fetch_cmd = str(args[1]) if len(args) > 1 else "" + uid_set = args[0].decode() if isinstance(args[0], bytes) else str(args[0]) + msg = msg101 if "101" in uid_set else msg202 + if "RFC822.SIZE" in fetch_cmd: + return "OK", [f"1 (UID {uid_set} RFC822.SIZE {len(msg)})".encode()] + return "OK", [(f"1 (UID {uid_set} RFC822 {{{len(msg)}}}".encode(), msg), b")"] + + conn = MultiMailboxConn() + client = _create_test_app(monkeypatch, mailbox=conn) + + res = client.post("/api/email/messages-download-bulk", json={ + "messages": [ + {"uid": "101", "folder": "folder-1"}, + {"uid": "202", "folder": "folder-2"}, + ] + }) + assert res.status_code == 200 + assert res.headers["x-exported-emails"] == "2" + assert res.headers["x-skipped-emails"] == "0" + zf = zipfile.ZipFile(io.BytesIO(res.content)) + assert len(zf.namelist()) == 2 + + +def test_messages_download_bulk_multi_group_genuinely_missing_message(monkeypatch): + """Genuinely missing messages (unreturned by preflight) do not cause 502; + they are skipped while verified messages in any group are successfully exported.""" + import io + import zipfile + + msg101 = b"From: a@example.com\r\nSubject: Exists\r\n\r\nBody" + + class MissingMailboxConn: + def __init__(self): + self.fetch_calls: list = [] + self.current_folder = "INBOX" + + def select(self, folder, readonly=True): + self.current_folder = str(folder) + return "OK", [b"1"] + + def uid(self, command, *args): + self.fetch_calls.append((self.current_folder, args)) + fetch_cmd = str(args[1]) if len(args) > 1 else "" + uid_set = args[0].decode() if isinstance(args[0], bytes) else str(args[0]) + if "RFC822.SIZE" in fetch_cmd: + # 101 exists on server; 999 is genuinely missing (unreported in size metadata) + res = [] + if "101" in uid_set: + res.append(f"1 (UID 101 RFC822.SIZE {len(msg101)})".encode()) + return "OK", res + # Body fetch only returns 101 + return "OK", [(f"1 (UID 101 RFC822 {{{len(msg101)}}}".encode(), msg101), b")"] + + conn = MissingMailboxConn() + client = _create_test_app(monkeypatch, mailbox=conn) + + res = client.post("/api/email/messages-download-bulk", json={ + "messages": [ + {"uid": "101", "folder": "folder-1"}, + {"uid": "999", "folder": "folder-1"}, + ] + }) + 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 + + # Verify 999 was never requested in body fetch + body_fetches = [ + call for folder, call in conn.fetch_calls + if "(UID RFC822)" in str(call) + ] + assert len(body_fetches) == 1 + assert "999" not in str(body_fetches[0]) + + +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..9ad84dc23 --- /dev/null +++ b/tests/test_email_library_print_browser.cjs @@ -0,0 +1,438 @@ +/** + * Browser print regression tests for Email Library print and PDF export. + * + * Requirements: + * - Node.js (v20+) + * - Playwright (`npm ci` or `npm install -D @playwright/test`) + * - Chromium browser binary: + * - Standard Playwright install: `npx playwright install chromium` + * - Or custom executable via env vars: + * - `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/path/to/chrome` + * - `CHROME_PATH=/path/to/chrome` + * - Or custom browsers cache dir via: + * - `PLAYWRIGHT_BROWSERS_PATH=/path/to/cache` + */ + +const http = require('http'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +let chromium; +try { + ({ chromium } = require('playwright')); +} catch (err) { + if (err && err.code === 'MODULE_NOT_FOUND') { + console.error( + 'Error: Playwright module not found.\n' + + 'Please install dependencies by running:\n' + + ' npm ci\n' + + 'or ensure NODE_PATH contains the playwright package.' + ); + process.exit(1); + } + throw err; +} + +const ROOT = path.resolve(__dirname, '..'); + +// Discover available Chromium executable using standard Playwright resolution, +// custom environment variables, standard cache paths, or system binaries. +function findChromiumPath() { + // 1. Explicit environment variable overrides + if (process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH && fs.existsSync(process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH)) { + return process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH; + } + if (process.env.CHROME_PATH && fs.existsSync(process.env.CHROME_PATH)) { + return process.env.CHROME_PATH; + } + + // 2. Playwright's configured/default executable path + try { + const defaultPath = chromium.executablePath(); + if (defaultPath && fs.existsSync(defaultPath)) { + return defaultPath; + } + } catch (e) { + // chromium.executablePath() may throw if no default browser is installed + } + + // 3. Inspect standard Playwright browser cache directories + const cacheDirs = []; + if (process.env.PLAYWRIGHT_BROWSERS_PATH) { + cacheDirs.push(process.env.PLAYWRIGHT_BROWSERS_PATH); + } + if (process.platform === 'win32') { + if (process.env.LOCALAPPDATA) { + cacheDirs.push(path.join(process.env.LOCALAPPDATA, 'ms-playwright')); + } + cacheDirs.push(path.join(os.homedir(), 'AppData', 'Local', 'ms-playwright')); + } else if (process.platform === 'darwin') { + cacheDirs.push(path.join(os.homedir(), 'Library', 'Caches', 'ms-playwright')); + } else { + cacheDirs.push(path.join(os.homedir(), '.cache', 'ms-playwright')); + } + + for (const cacheDir of cacheDirs) { + if (!fs.existsSync(cacheDir)) continue; + try { + const entries = fs.readdirSync(cacheDir).sort().reverse(); + for (const entry of entries) { + const entryPath = path.join(cacheDir, entry); + const candidates = [ + path.join(entryPath, 'chrome-headless-shell-linux64', 'chrome-headless-shell'), + path.join(entryPath, 'chrome-linux64', 'chrome'), + path.join(entryPath, 'chrome-linux', 'chrome'), + path.join(entryPath, 'chrome-win64', 'chrome.exe'), + path.join(entryPath, 'chrome-win', 'chrome.exe'), + path.join(entryPath, 'chrome-mac', 'Chromium.app', 'Contents', 'MacOS', 'Chromium'), + ]; + for (const candidate of candidates) { + if (fs.existsSync(candidate)) { + return candidate; + } + } + } + } catch (e) {} + } + + // 4. System browser fallbacks + const systemCandidates = process.platform === 'win32' ? [ + path.join(process.env['PROGRAMFILES'] || 'C:\\Program Files', 'Google', 'Chrome', 'Application', 'chrome.exe'), + path.join(process.env['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)', 'Google', 'Chrome', 'Application', 'chrome.exe'), + ] : [ + '/usr/bin/google-chrome', + '/usr/bin/chromium', + '/usr/bin/chromium-browser', + '/snap/bin/chromium', + ]; + for (const sc of systemCandidates) { + if (fs.existsSync(sc)) return sc; + } + + 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; + + let browser; + try { + browser = await chromium.launch(launchOpts); + } catch (launchErr) { + throw new Error( + `Failed to launch Chromium browser: ${launchErr.message}\n` + + 'Missing or unexecutable Chromium binary.\n' + + 'To install Playwright Chromium, run:\n' + + ' npx playwright install chromium\n' + + 'Or set PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH to a valid Chromium executable.' + ); + } + 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('