Merge pull request #6742 from odysseus-dev/codex/review-7011-email-export-20261015

feat(email): export selected messages as EML or print to PDF
This commit is contained in:
Alexandre Teixeira
2026-10-20 16:37:53 +01:00
committed by GitHub
5 changed files with 2004 additions and 18 deletions
+419 -3
View File
@@ -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"<fixture-{uid}@mail.local>"),
):
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,
+202 -7
View File
@@ -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(
`<div class="email-export-pdf-omitted-notice">Note: ${count} selected email${count === 1 ? ' was' : 's were'} unavailable and omitted from this print.</div>`
);
}
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 <img> 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
fieldParts.push(`<div class="email-export-pdf-field"><span class="email-export-pdf-label">${esc(lbl)}</span><span>${esc(val)}</span></div>`);
}
const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
articles.push(
`<article class="email-export-pdf-message">` +
`<h1>${esc(subject)}</h1>` +
`<div class="email-export-pdf-fields">${fieldParts.join('')}</div>` +
`<div class="email-export-pdf-body">${bodyHtml}</div>` +
`</article>`
);
}
const printCss = [
'html,body{background:#fff;color:#171717;font:12px/1.55 Arial,Helvetica,sans-serif;margin:0;padding:0}',
'.email-export-pdf{max-width:794px;margin:0 auto;padding:38px;box-sizing:border-box}',
'.email-export-pdf-message{page-break-after:always;break-after:page}',
'.email-export-pdf-message:last-child{page-break-after:auto;break-after:auto}',
'.email-export-pdf-message h1{margin:0 0 18px;font-size:20px;line-height:1.3;overflow-wrap:anywhere}',
'.email-export-pdf-fields{margin:0 0 20px;padding:0 0 14px;border-bottom:1px solid #d8d8d8}',
'.email-export-pdf-field{display:flex;gap:10px;margin:3px 0;overflow-wrap:anywhere}',
'.email-export-pdf-label{width:54px;flex:0 0 54px;color:#666}',
'.email-export-pdf-body{overflow-wrap:anywhere;word-break:break-word}',
'.email-export-pdf-body img{max-width:100%}',
'.email-export-pdf-body a{color:#135f9b;text-decoration:underline}',
'.email-export-pdf-body blockquote{margin-left:12px;padding-left:10px;border-left:2px solid #ccc;color:#555}',
'.email-export-pdf-body pre{white-space:pre-wrap}',
'.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('<!DOCTYPE html><html><head><meta charset="utf-8"></head><body></body></html>');
printDoc.close();
printDoc.title = safeBaseName;
const printStyle = printDoc.createElement('style');
printStyle.textContent = printCss;
printDoc.head.appendChild(printStyle);
const container = printDoc.createElement('div');
container.className = 'email-export-pdf';
container.innerHTML = articles.join('');
printDoc.body.appendChild(container);
await printDoc.fonts.ready;
frame.contentWindow?.addEventListener('afterprint', 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.
+44 -2
View File
@@ -25,6 +25,8 @@ import {
_animateEmailCardRemoval,
_clearDoneResponseTagsLocal,
_emailMutationQuery,
_exportSelectedEmailPdf,
_exportSelectedEmails,
_exportSelectedAttachments,
_findSiblingEmailCard,
_fitEmailDropdown,
@@ -585,7 +587,10 @@ export function _showBulkActionsMenu(anchor) {
const _unreadIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3" fill="currentColor"/></svg>';
const _doneIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
const _exportIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>';
const _emailIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>';
const _pdfIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><path d="M8 15h8M8 18h5"/></svg>';
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 = `<span class="dropdown-icon">${a.icon}</span><span>${a.label}</span>`;
it.addEventListener('click', (e) => { e.stopPropagation(); close(); a.action(); });
it.className += a.submenu ? ' email-bulk-submenu-trigger' : '';
it.innerHTML = `<span class="dropdown-icon">${a.icon}</span><span>${a.label}</span>${a.submenu ? '<span style="margin-left:auto;opacity:0.55;">›</span>' : ''}`;
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 = '<span style="opacity:.65;margin-right:4px;">‹</span><span>Export Emails</span>';
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 = `<span class="dropdown-icon">${option.icon}</span><span>${option.label}</span>`;
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.
+901 -6
View File
@@ -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
+438
View File
@@ -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 = [
'<div>',
'<p>Email with diverse images</p>',
'<!-- Inline image with alt text -->',
'<img src="cid:logo123" alt="Company Brand Logo">',
'<!-- Remote image with title -->',
'<img src="https://example.com/tracker.png" title="Tracking Pixel">',
'<!-- Image without alt or title -->',
'<img src="https://example.com/no-alt.jpg">',
'<!-- Interactive control that shouldn\'t be in print -->',
'<button type="button" class="btn-action">Interactive Action</button>',
'<!-- XSS payload -->',
'<script>window.__pwned = true;</script>',
'</div>'
].join('\n');
} else if (uid === 'long_msg') {
body_html = `<div><p>${'A'.repeat(5000)}</p></div>`;
} else {
body_html = `<div><p>Standard message body for ${uid}</p></div>`;
}
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('<script>') || printDocHtml.includes('window.__pwned'),
};
}, origin);
results.printImageSanitization = printImageResult;
// TEST 2: Partial Failure - 1 failure out of 3 emails
const partialFailureResult = 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`);
const messages = [
{ uid: 'msg1', subject: 'Email One', folder: 'INBOX' },
{ uid: 'fail_uid', subject: 'Failing Email', folder: 'INBOX' },
{ uid: 'msg2', subject: 'Email Two', folder: 'INBOX' },
];
stateMod.state._selectedUids = new Set(messages.map(m => m.uid));
stateMod.state._libEmails = messages;
let printInvoked = false;
let printDocHtml = '';
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() {
printInvoked = true;
printDocHtml = frame.contentDocument ? frame.contentDocument.documentElement.outerHTML : '';
frame.contentWindow.dispatchEvent(new Event('afterprint'));
};
}
});
observer.observe(document.body, { childList: true });
await mod._exportSelectedEmailPdf();
observer.disconnect();
const toast = document.getElementById('toast')?.textContent || '';
return {
printInvoked,
toast,
hasMsg1: printDocHtml.includes('Subject msg1'),
hasMsg2: printDocHtml.includes('Subject msg2'),
hasOmittedNotice: printDocHtml.includes('1 selected email was unavailable and omitted from this print'),
};
}, origin);
results.partialFailureTest = partialFailureResult;
// TEST 3: All emails fail
const allFailedResult = 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`);
const messages = [
{ uid: 'fail_uid', subject: 'Fail 1', folder: 'INBOX' },
{ uid: 'net_fail', subject: 'Fail 2', folder: 'INBOX' },
];
stateMod.state._selectedUids = new Set(messages.map(m => m.uid));
stateMod.state._libEmails = messages;
let printInvoked = 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() {
printInvoked = true;
};
}
});
observer.observe(document.body, { childList: true });
let thrownError = null;
try {
await mod._exportSelectedEmailPdf();
} catch (err) {
thrownError = err.message || String(err);
}
observer.disconnect();
const frame = document.getElementById('email-browser-print-frame');
const toast = document.getElementById('toast')?.textContent || '';
return {
printInvoked,
frameOpened: frame !== null,
thrownError,
toast,
};
}, origin);
results.allFailedTest = allFailedResult;
// TEST 4: Idempotent Iframe Cleanup & Window Afterprint
const cleanupResult = 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(['msg1']);
stateMod.state._libEmails = [{ uid: 'msg1', subject: 'Cleanup Test', folder: 'INBOX' }];
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() {
window.dispatchEvent(new Event('afterprint'));
};
}
});
observer.observe(document.body, { childList: true });
await mod._exportSelectedEmailPdf();
observer.disconnect();
const frameAfter = document.getElementById('email-browser-print-frame');
return {
frameRemovedViaWindowAfterprint: frameAfter === null,
};
}, origin);
results.cleanupTest = cleanupResult;
} finally {
await browser.close();
server.close();
}
// Assertions
const t1 = results.printImageSanitization;
if (!t1.iframePrintInvoked) throw new Error('Assertion failed: print not invoked');
if (!t1.frameRemovedAfterPrint) throw new Error('Assertion failed: frame not removed');
if (!t1.hasLogoNotice) throw new Error('Assertion failed: [Image: Company Brand Logo] missing');
if (!t1.hasTrackerNotice) throw new Error('Assertion failed: [Image: Tracking Pixel] missing');
if (!t1.hasNoAltNotice) throw new Error('Assertion failed: [Image omitted] missing');
if (t1.hasPlaceholderElement) throw new Error('Assertion failed: .email-inline-image-placeholder found in print doc');
if (t1.hasRemoteImageBlockedText) throw new Error('Assertion failed: Remote image blocked leaked into print doc');
if (t1.hasDownloadButton) throw new Error('Assertion failed: download button leaked into print doc');
if (t1.hasInteractiveActionBtn) throw new Error('Assertion failed: interactive button leaked into print doc');
if (t1.hasXSS) throw new Error('Assertion failed: script tag found');
const t2 = results.partialFailureTest;
if (!t2.printInvoked) throw new Error('Assertion failed: partial print was not invoked');
if (!t2.hasMsg1 || !t2.hasMsg2) throw new Error('Assertion failed: successful messages missing from print');
if (!t2.hasOmittedNotice) throw new Error('Assertion failed: omitted notice banner missing');
if (!t2.toast.includes('Prepared 2 emails for printing; 1 unavailable')) {
throw new Error('Assertion failed: toast text mismatch: ' + t2.toast);
}
const t3 = results.allFailedTest;
if (t3.printInvoked) throw new Error('Assertion failed: print should NOT be invoked when all fail');
if (t3.frameOpened) throw new Error('Assertion failed: print frame should NOT remain in DOM');
const t4 = results.cleanupTest;
if (!t4.frameRemovedViaWindowAfterprint) throw new Error('Assertion failed: top window afterprint did not clean up frame');
console.log('ALL_BROWSER_TESTS_PASSED');
}
run().catch(err => {
console.error('Browser test failed:', err);
process.exit(1);
});