mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-21 06:12:21 +02:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fd4e0e9bb |
@@ -25,16 +25,13 @@ import json
|
|||||||
import re
|
import re
|
||||||
import html
|
import html
|
||||||
import io
|
import io
|
||||||
import tempfile
|
|
||||||
import zipfile
|
import zipfile
|
||||||
from urllib.parse import parse_qs, quote, unquote, urlparse
|
from urllib.parse import parse_qs, unquote, urlparse
|
||||||
from html.parser import HTMLParser as _HTMLParser
|
from html.parser import HTMLParser as _HTMLParser
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime
|
||||||
from pathlib import Path
|
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.text import MIMEText
|
||||||
from email.mime.multipart import MIMEMultipart
|
from email.mime.multipart import MIMEMultipart
|
||||||
@@ -639,15 +636,10 @@ def _message_id_chain(*values: str) -> list[str]:
|
|||||||
|
|
||||||
|
|
||||||
def _uid_from_fetch_meta(meta_b: bytes) -> str:
|
def _uid_from_fetch_meta(meta_b: bytes) -> str:
|
||||||
m = re.search(rb"\bUID\s+(\d+)\b", meta_b, re.IGNORECASE)
|
m = re.search(rb"\bUID\s+(\d+)\b", meta_b)
|
||||||
return m.group(1).decode() if m else ""
|
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]:
|
def _parse_list_unsubscribe_header(value: str | None) -> list[dict]:
|
||||||
"""Parse RFC List-Unsubscribe entries into safe reviewable actions.
|
"""Parse RFC List-Unsubscribe entries into safe reviewable actions.
|
||||||
|
|
||||||
@@ -4193,414 +4185,6 @@ def setup_email_routes():
|
|||||||
headers={"Content-Disposition": f'attachment; filename="{zip_name}"'},
|
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}")
|
@router.get("/inline-image/{uid}")
|
||||||
async def inline_image(
|
async def inline_image(
|
||||||
uid: str,
|
uid: str,
|
||||||
|
|||||||
@@ -2690,11 +2690,14 @@ body.theme-frosted .modal {
|
|||||||
max-height: 95dvh !important;
|
max-height: 95dvh !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Notes and Documents must respect the same viewport and safe-area
|
||||||
|
top as other mobile full-height sheets. Keep these authoritative rules
|
||||||
|
consistent with 05-documents.css and 14-email.css. */
|
||||||
body.doc-view .doc-editor-pane,
|
body.doc-view .doc-editor-pane,
|
||||||
body.notes-view .notes-pane {
|
body.notes-view .notes-pane {
|
||||||
inset: auto 0 0 !important;
|
inset: var(--sheet-top) 0 0 !important;
|
||||||
height: 95dvh !important;
|
height: calc(var(--app-height) - var(--sheet-top)) !important;
|
||||||
max-height: 95dvh !important;
|
max-height: calc(var(--app-height) - var(--sheet-top)) !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,13 +16,6 @@
|
|||||||
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
|
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
|
||||||
<script nonce="{{CSP_NONCE}}">
|
<script nonce="{{CSP_NONCE}}">
|
||||||
window._odysseusLoadTime = Date.now();
|
window._odysseusLoadTime = Date.now();
|
||||||
// Stops iOS focus-zoom on every field; iOS still allows pinch-zoom, other platforms would not.
|
|
||||||
(function(){
|
|
||||||
var ua = navigator.userAgent || '';
|
|
||||||
var isIOS = /iPad|iPhone|iPod/.test(ua) || (/Macintosh/.test(ua) && navigator.maxTouchPoints > 1);
|
|
||||||
var vp = document.querySelector('meta[name="viewport"]');
|
|
||||||
if (isIOS && vp) vp.setAttribute('content', vp.getAttribute('content') + ', maximum-scale=1');
|
|
||||||
})();
|
|
||||||
(function(){
|
(function(){
|
||||||
try {
|
try {
|
||||||
var t = JSON.parse(localStorage.getItem('odysseus-theme'));
|
var t = JSON.parse(localStorage.getItem('odysseus-theme'));
|
||||||
|
|||||||
@@ -1123,175 +1123,7 @@ function _filenameFromContentDisposition(header) {
|
|||||||
return bare ? bare[1].trim() : '';
|
return bare ? bare[1].trim() : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function _printSelectedEmails(messages) {
|
export async function _exportSelectedAttachments() {
|
||||||
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
||||||
fieldParts.push(`<div class="email-export-pdf-field"><span class="email-export-pdf-label">${esc(lbl)}</span><span>${esc(val)}</span></div>`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const esc = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
||||||
articles.push(
|
|
||||||
`<article class="email-export-pdf-message">` +
|
|
||||||
`<h1>${esc(subject)}</h1>` +
|
|
||||||
`<div class="email-export-pdf-fields">${fieldParts.join('')}</div>` +
|
|
||||||
`<div class="email-export-pdf-body">${bodyHtml}</div>` +
|
|
||||||
`</article>`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const printCss = [
|
|
||||||
'html,body{background:#fff;color:#171717;font:12px/1.55 Arial,Helvetica,sans-serif;margin:0;padding:0}',
|
|
||||||
'.email-export-pdf{max-width:794px;margin:0 auto;padding:38px;box-sizing:border-box}',
|
|
||||||
'.email-export-pdf-message{page-break-after:always;break-after:page}',
|
|
||||||
'.email-export-pdf-message:last-child{page-break-after:auto;break-after:auto}',
|
|
||||||
'.email-export-pdf-message h1{margin:0 0 18px;font-size:20px;line-height:1.3;overflow-wrap:anywhere}',
|
|
||||||
'.email-export-pdf-fields{margin:0 0 20px;padding:0 0 14px;border-bottom:1px solid #d8d8d8}',
|
|
||||||
'.email-export-pdf-field{display:flex;gap:10px;margin:3px 0;overflow-wrap:anywhere}',
|
|
||||||
'.email-export-pdf-label{width:54px;flex:0 0 54px;color:#666}',
|
|
||||||
'.email-export-pdf-body{overflow-wrap:anywhere;word-break:break-word}',
|
|
||||||
'.email-export-pdf-body img{max-width:100%}',
|
|
||||||
'.email-export-pdf-body a{color:#135f9b;text-decoration:underline}',
|
|
||||||
'.email-export-pdf-body blockquote{margin-left:12px;padding-left:10px;border-left:2px solid #ccc;color:#555}',
|
|
||||||
'.email-export-pdf-body pre{white-space:pre-wrap}',
|
|
||||||
'.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();
|
const messages = _selectedEmailExportPayload();
|
||||||
if (!messages.length) {
|
if (!messages.length) {
|
||||||
showToast('Select emails first');
|
showToast('Select emails first');
|
||||||
@@ -1319,14 +1151,8 @@ async function _exportSelectedBundle(kind) {
|
|||||||
if (deleteBtn) deleteBtn.disabled = true;
|
if (deleteBtn) deleteBtn.disabled = true;
|
||||||
if (cancelBtn) cancelBtn.disabled = true;
|
if (cancelBtn) cancelBtn.disabled = true;
|
||||||
if (selectAll) selectAll.disabled = true;
|
if (selectAll) selectAll.disabled = true;
|
||||||
const isEmailExport = kind === 'emails';
|
if (countEl) countEl.textContent = `Exporting attachments from ${messages.length}…`;
|
||||||
const isPrintExport = kind === 'print';
|
const res = await fetch(`${API_BASE}/api/email/attachments-download-bulk`, {
|
||||||
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',
|
method: 'POST',
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -1344,21 +1170,12 @@ async function _exportSelectedBundle(kind) {
|
|||||||
throw new Error(detail || `HTTP ${res.status}`);
|
throw new Error(detail || `HTTP ${res.status}`);
|
||||||
}
|
}
|
||||||
const blob = await res.blob();
|
const blob = await res.blob();
|
||||||
const filename = _filenameFromContentDisposition(res.headers.get('Content-Disposition'))
|
const filename = _filenameFromContentDisposition(res.headers.get('Content-Disposition')) || 'selected-email-attachments.zip';
|
||||||
|| (isEmailExport
|
|
||||||
? (messages.length === 1 ? 'selected-email.eml' : 'selected-emails.zip')
|
|
||||||
: 'selected-email-attachments.zip');
|
|
||||||
_downloadBlob(blob, filename);
|
_downloadBlob(blob, filename);
|
||||||
const exported = Number(res.headers.get('X-Exported-Emails')) || messages.length;
|
showToast(`Exported attachments from ${messages.length} selected email${messages.length === 1 ? '' : 's'}`);
|
||||||
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) {
|
} catch (err) {
|
||||||
console.error(`Failed to export selected email ${kind}:`, err);
|
console.error('Failed to export selected email attachments:', err);
|
||||||
showToast(err?.message || (kind === 'emails' ? 'Failed to export emails' : kind === 'print' ? 'Could not open browser print' : 'Failed to export attachments'));
|
showToast(err?.message || 'Failed to export attachments');
|
||||||
} finally {
|
} finally {
|
||||||
if (busySpinner) busySpinner.destroy();
|
if (busySpinner) busySpinner.destroy();
|
||||||
if (actionsBtn) {
|
if (actionsBtn) {
|
||||||
@@ -1374,18 +1191,6 @@ async function _exportSelectedBundle(kind) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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.
|
// 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
|
// Every email route call in this file goes through here so switching accounts
|
||||||
// is a single-variable flip.
|
// is a single-variable flip.
|
||||||
|
|||||||
@@ -25,8 +25,6 @@ import {
|
|||||||
_animateEmailCardRemoval,
|
_animateEmailCardRemoval,
|
||||||
_clearDoneResponseTagsLocal,
|
_clearDoneResponseTagsLocal,
|
||||||
_emailMutationQuery,
|
_emailMutationQuery,
|
||||||
_exportSelectedEmailPdf,
|
|
||||||
_exportSelectedEmails,
|
|
||||||
_exportSelectedAttachments,
|
_exportSelectedAttachments,
|
||||||
_findSiblingEmailCard,
|
_findSiblingEmailCard,
|
||||||
_fitEmailDropdown,
|
_fitEmailDropdown,
|
||||||
@@ -587,10 +585,7 @@ 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 _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 _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 _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 = [
|
const items = [
|
||||||
{ label: 'Export Emails', icon: _exportIco, submenu: true },
|
|
||||||
{ label: 'Export Attachments', icon: _exportIco, action: () => _exportSelectedAttachments() },
|
{ label: 'Export Attachments', icon: _exportIco, action: () => _exportSelectedAttachments() },
|
||||||
{ label: 'Done', icon: _doneIco, action: () => _bulkAction('done') },
|
{ label: 'Done', icon: _doneIco, action: () => _bulkAction('done') },
|
||||||
{ label: 'Mark Read', icon: _readIco, action: () => _bulkAction('read') },
|
{ label: 'Mark Read', icon: _readIco, action: () => _bulkAction('read') },
|
||||||
@@ -599,45 +594,8 @@ export function _showBulkActionsMenu(anchor) {
|
|||||||
for (const a of items) {
|
for (const a of items) {
|
||||||
const it = document.createElement('div');
|
const it = document.createElement('div');
|
||||||
it.className = 'dropdown-item-compact' + (a.danger ? ' dropdown-item-danger' : '');
|
it.className = 'dropdown-item-compact' + (a.danger ? ' dropdown-item-danger' : '');
|
||||||
it.className += a.submenu ? ' email-bulk-submenu-trigger' : '';
|
it.innerHTML = `<span class="dropdown-icon">${a.icon}</span><span>${a.label}</span>`;
|
||||||
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(); close(); a.action(); });
|
||||||
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);
|
dropdown.appendChild(it);
|
||||||
}
|
}
|
||||||
// Mobile-only Cancel — matches the per-card and sidebar dropdowns.
|
// Mobile-only Cancel — matches the per-card and sidebar dropdowns.
|
||||||
|
|||||||
@@ -379,7 +379,6 @@ document.addEventListener('DOMContentLoaded', markComposerUserEdited, { once: tr
|
|||||||
root.classList.toggle('keyboard-open', keyboardOpen);
|
root.classList.toggle('keyboard-open', keyboardOpen);
|
||||||
if (keyboardOpen) root.style.setProperty('--keyboard-viewport-height', vv.height + 'px');
|
if (keyboardOpen) root.style.setProperty('--keyboard-viewport-height', vv.height + 'px');
|
||||||
else root.style.removeProperty('--keyboard-viewport-height');
|
else root.style.removeProperty('--keyboard-viewport-height');
|
||||||
if (window.scrollY) window.scrollTo(0, 0);
|
|
||||||
};
|
};
|
||||||
// iOS fires visualViewport resize only once, mid-animation, and no focusin when
|
// iOS fires visualViewport resize only once, mid-animation, and no focusin when
|
||||||
// the tapped field already had focus (e.g. the composer after a new chat)
|
// the tapped field already had focus (e.g. the composer after a new chat)
|
||||||
@@ -392,7 +391,6 @@ document.addEventListener('DOMContentLoaded', markComposerUserEdited, { once: tr
|
|||||||
window.visualViewport.addEventListener('scroll', _syncKeyboardViewport);
|
window.visualViewport.addEventListener('scroll', _syncKeyboardViewport);
|
||||||
document.addEventListener('focusin', _syncThroughKeyboardAnimation);
|
document.addEventListener('focusin', _syncThroughKeyboardAnimation);
|
||||||
document.addEventListener('focusout', _syncThroughKeyboardAnimation);
|
document.addEventListener('focusout', _syncThroughKeyboardAnimation);
|
||||||
window.addEventListener('scroll', () => { if (window.scrollY) window.scrollTo(0, 0); }, { passive: true });
|
|
||||||
|
|
||||||
let _lastVVHeight = window.visualViewport.height;
|
let _lastVVHeight = window.visualViewport.height;
|
||||||
window.visualViewport.addEventListener('resize', function() {
|
window.visualViewport.addEventListener('resize', function() {
|
||||||
|
|||||||
@@ -267,8 +267,9 @@ export function makeWindowResizable(content, options = {}) {
|
|||||||
content.style.maxWidth = 'none';
|
content.style.maxWidth = 'none';
|
||||||
content.style.maxHeight = 'none';
|
content.style.maxHeight = 'none';
|
||||||
const left = parseFloat(content.style.left);
|
const left = parseFloat(content.style.left);
|
||||||
if (Number.isFinite(left) && left + w > window.innerWidth - padRight) {
|
if (Number.isFinite(left)) {
|
||||||
content.style.left = Math.max(padLeft, window.innerWidth - padRight - w) + 'px';
|
const rightmostLeft = Math.max(padLeft, window.innerWidth - padRight - w);
|
||||||
|
content.style.left = Math.max(padLeft, Math.min(left, rightmostLeft)) + 'px';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
// Tests against the running Odysseus app, not a static mock page.
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const os = require('node:os');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { defineConfig } = require('@playwright/test');
|
||||||
|
|
||||||
|
const repositoryRoot = path.resolve(__dirname, '../..');
|
||||||
|
const port = Number(process.env.ODYSSEUS_LAYOUT_PORT || '17739');
|
||||||
|
const baseURL = `http://127.0.0.1:${port}`;
|
||||||
|
const pythonInVenv = path.join(repositoryRoot, '.venv', 'bin', 'python');
|
||||||
|
const python = process.env.ODYSSEUS_TEST_PYTHON || (fs.existsSync(pythonInVenv) ? pythonInVenv : 'python3');
|
||||||
|
const dataDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'odysseus-pr6396-'));
|
||||||
|
const evidenceDir = process.env.ODYSSEUS_LAYOUT_EVIDENCE_DIR || path.join(os.homedir(), 'Pictures', 'Odysseus-6396-followup');
|
||||||
|
|
||||||
|
module.exports = defineConfig({
|
||||||
|
testDir: path.join(__dirname, 'ios-layout'),
|
||||||
|
testMatch: ['**/*.spec.cjs'],
|
||||||
|
outputDir: path.join(dataDirectory, 'playwright-output'),
|
||||||
|
timeout: 90_000,
|
||||||
|
expect: { timeout: 15_000 },
|
||||||
|
retries: 0,
|
||||||
|
workers: 1,
|
||||||
|
reporter: [['line']],
|
||||||
|
use: {
|
||||||
|
baseURL,
|
||||||
|
headless: true,
|
||||||
|
serviceWorkers: 'block',
|
||||||
|
screenshot: 'only-on-failure',
|
||||||
|
trace: 'retain-on-failure',
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{ name: 'desktop-chromium', use: { browserName: 'chromium', viewport: { width: 1440, height: 900 } } },
|
||||||
|
{ name: 'phone-webkit-portrait', use: { browserName: 'webkit', viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true } },
|
||||||
|
{ name: 'phone-webkit-landscape', use: { browserName: 'webkit', viewport: { width: 844, height: 390 }, hasTouch: true, isMobile: true } },
|
||||||
|
{ name: 'tablet-webkit', use: { browserName: 'webkit', viewport: { width: 820, height: 1000 }, hasTouch: true, isMobile: true } },
|
||||||
|
],
|
||||||
|
metadata: { evidenceDir },
|
||||||
|
webServer: {
|
||||||
|
command: `${JSON.stringify(python)} -m uvicorn app:app --host 127.0.0.1 --port ${port}`,
|
||||||
|
cwd: repositoryRoot,
|
||||||
|
url: baseURL,
|
||||||
|
timeout: 120_000,
|
||||||
|
reuseExistingServer: false,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
AUTH_ENABLED: 'false',
|
||||||
|
// Never connect the disposable app to the host's real ChromaDB.
|
||||||
|
CHROMADB_HOST: '127.0.0.1',
|
||||||
|
CHROMADB_PORT: String(port + 1),
|
||||||
|
LLM_HOST: '127.0.0.1',
|
||||||
|
EMBEDDING_URL: `http://127.0.0.1:${port + 1}/v1/embeddings`,
|
||||||
|
DATABASE_URL: `sqlite:///${path.join(dataDirectory, 'app.db')}`,
|
||||||
|
ODYSSEUS_DATA_DIR: dataDirectory,
|
||||||
|
ODYSSEUS_STARTUP_WARMUPS: '0',
|
||||||
|
RESPONSE_COMPRESSION_ENABLED: 'false',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
async function openApp(page) {
|
||||||
|
const response = await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||||
|
expect(response?.ok()).toBeTruthy();
|
||||||
|
// The runtime currently produces two #message nodes. Select the
|
||||||
|
// initialized composer while diagnosing whether both are visible.
|
||||||
|
const composers = await page.locator('textarea#message').evaluateAll(nodes =>
|
||||||
|
nodes.map((el, index) => ({
|
||||||
|
index,
|
||||||
|
initialized: el.dataset.startupPreserveBound === '1',
|
||||||
|
visible: !!el.getClientRects().length &&
|
||||||
|
getComputedStyle(el).visibility !== 'hidden',
|
||||||
|
parent: el.parentElement?.className || '',
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
if (composers.length !== 1) {
|
||||||
|
console.log('Composer DOM diagnostic:', JSON.stringify(composers));
|
||||||
|
}
|
||||||
|
const activeComposer = page.locator(
|
||||||
|
'textarea#message[data-startup-preserve-bound="1"]'
|
||||||
|
);
|
||||||
|
await expect(activeComposer).toHaveCount(1);
|
||||||
|
await expect(activeComposer).toBeVisible();
|
||||||
|
expect(
|
||||||
|
composers.filter(composer => composer.visible).length,
|
||||||
|
'Exactly one composer should be visible'
|
||||||
|
).toBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('renders actual Odysseus UI and captures review screenshots', async ({ page }, testInfo) => {
|
||||||
|
await openApp(page);
|
||||||
|
const out = testInfo.config.metadata.evidenceDir;
|
||||||
|
fs.mkdirSync(out, { recursive: true });
|
||||||
|
const name = testInfo.project.name;
|
||||||
|
await page.screenshot({ path: path.join(out, `${name}-chat.png`), fullPage: false, animations: 'disabled' });
|
||||||
|
|
||||||
|
// Screenshot the actual Settings UI only if a real user-visible Settings
|
||||||
|
// control is reachable; never fabricate a modal for PR screenshots.
|
||||||
|
const settingsButton = page.locator('#rail-settings:visible, #user-bar-settings:visible').first();
|
||||||
|
if (await settingsButton.count()) {
|
||||||
|
await settingsButton.click();
|
||||||
|
const dialog = page.locator('#settings-modal');
|
||||||
|
if (await dialog.isVisible()) {
|
||||||
|
await page.screenshot({ path: path.join(out, `${name}-settings.png`), fullPage: false, animations: 'disabled' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open the real Notes route via an existing UI action when it is reachable.
|
||||||
|
// These screenshots reflect genuine runtime content, not test-injected panes.
|
||||||
|
await openApp(page);
|
||||||
|
const notesButton = page.locator('#rail-notes:visible, #tool-notes-btn:visible').first();
|
||||||
|
if (await notesButton.count()) {
|
||||||
|
await notesButton.click();
|
||||||
|
const realNotes = page.locator('.notes-pane:visible').first();
|
||||||
|
if (await realNotes.count()) {
|
||||||
|
await page.screenshot({ path: path.join(out, `${name}-notes.png`), fullPage: false, animations: 'disabled' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
testInfo.annotations.push({ type: 'evidence', description: out });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('viewport meta does not restrict user zoom', async ({ page }) => {
|
||||||
|
await openApp(page);
|
||||||
|
const meta = await page.locator('meta[name="viewport"]').getAttribute('content');
|
||||||
|
expect(meta || '').not.toMatch(/maximum-scale\s*=\s*1(?:\b|\.)/i);
|
||||||
|
expect(meta || '').not.toMatch(/user-scalable\s*=\s*no/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('browser-owned window scrolling is not globally reset', async ({ page }) => {
|
||||||
|
await openApp(page);
|
||||||
|
const value = await page.evaluate(async () => {
|
||||||
|
document.documentElement.style.overflowY = 'auto';
|
||||||
|
document.body.style.overflowY = 'auto';
|
||||||
|
document.body.style.height = 'auto';
|
||||||
|
document.body.style.minHeight = '3200px';
|
||||||
|
const spacer = document.createElement('div');
|
||||||
|
spacer.style.cssText = 'position:absolute;top:3100px;width:1px;height:10px;pointer-events:none;';
|
||||||
|
document.body.appendChild(spacer);
|
||||||
|
window.scrollTo(0, 250);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 150));
|
||||||
|
return window.scrollY;
|
||||||
|
});
|
||||||
|
expect(value).toBeGreaterThan(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mobile Notes and Documents panes honor the safe-area top', async ({ page }, testInfo) => {
|
||||||
|
test.skip(testInfo.project.name !== 'phone-webkit-portrait', 'Only mobile <=768px');
|
||||||
|
await openApp(page);
|
||||||
|
const results = await page.evaluate(() => {
|
||||||
|
document.documentElement.style.setProperty('--safe-top', '44px');
|
||||||
|
document.documentElement.style.setProperty('--sheet-top', '68px');
|
||||||
|
document.documentElement.style.setProperty('--app-height', '844px');
|
||||||
|
const values = {};
|
||||||
|
for (const [key, bodyClass, paneClass] of [
|
||||||
|
['notes', 'notes-view', 'notes-pane'],
|
||||||
|
['document', 'doc-view', 'doc-editor-pane'],
|
||||||
|
]) {
|
||||||
|
const pane = document.createElement('section');
|
||||||
|
pane.className = paneClass;
|
||||||
|
pane.style.opacity = '0'; // CSS-only fixture, not a screenshot of fake app content
|
||||||
|
document.body.appendChild(pane);
|
||||||
|
document.body.classList.add(bodyClass);
|
||||||
|
const style = window.getComputedStyle(pane);
|
||||||
|
values[key] = {
|
||||||
|
top: style.top,
|
||||||
|
height: style.height,
|
||||||
|
insetTop: pane.getBoundingClientRect().top,
|
||||||
|
};
|
||||||
|
pane.remove();
|
||||||
|
document.body.classList.remove(bodyClass);
|
||||||
|
}
|
||||||
|
return values;
|
||||||
|
});
|
||||||
|
for (const key of ['notes', 'document']) {
|
||||||
|
expect(results[key].top, key).toBe('68px');
|
||||||
|
expect(parseFloat(results[key].height), key).toBeGreaterThanOrEqual(770);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('restored floating windows clamp away from a left safe area', async ({ page }, testInfo) => {
|
||||||
|
test.skip(testInfo.project.name !== 'desktop-chromium', 'Geometry test uses desktop resizable windows');
|
||||||
|
await openApp(page);
|
||||||
|
const value = await page.evaluate(async () => {
|
||||||
|
document.documentElement.style.setProperty('--safe-left', '58px');
|
||||||
|
document.body.style.setProperty('padding-left', '58px', 'important');
|
||||||
|
const probe = document.createElement('div');
|
||||||
|
probe.style.cssText = 'position:fixed;left:10px;top:80px;width:420px;height:250px;z-index:999999;';
|
||||||
|
document.body.appendChild(probe);
|
||||||
|
const key = 'odysseus-layout-restore-test';
|
||||||
|
localStorage.setItem(key, JSON.stringify({ w: 480, h: 280 }));
|
||||||
|
try {
|
||||||
|
// Body horizontal padding transitions over 160ms. Wait for the
|
||||||
|
// simulated safe-area change to settle before restoring geometry.
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 300));
|
||||||
|
|
||||||
|
const padAtRestore = parseFloat(
|
||||||
|
getComputedStyle(document.body).paddingLeft
|
||||||
|
);
|
||||||
|
console.log('PADDING AT RESTORE:', padAtRestore);
|
||||||
|
|
||||||
|
if (Math.abs(padAtRestore - 58) > 0.5) {
|
||||||
|
throw new Error(
|
||||||
|
`Safe-area fixture did not settle: ${padAtRestore}px`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the running server actually serves the patched module.
|
||||||
|
const response = await fetch(
|
||||||
|
'/static/js/windowResize.js',
|
||||||
|
{ cache: 'no-store' }
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Cannot fetch windowResize.js');
|
||||||
|
}
|
||||||
|
const moduleSource = await response.text();
|
||||||
|
if (!moduleSource.includes('const rightmostLeft = Math.max(padLeft')) {
|
||||||
|
throw new Error('Running server is serving unpatched windowResize.js');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { makeWindowResizable } = await import('/static/js/windowResize.js');
|
||||||
|
makeWindowResizable(probe, { storageKey: key, mobileSkip: 768 });
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 300));
|
||||||
|
return {
|
||||||
|
left: parseFloat(probe.style.left),
|
||||||
|
width: parseFloat(probe.style.width),
|
||||||
|
paddingLeft: parseFloat(getComputedStyle(document.body).paddingLeft),
|
||||||
|
viewportWidth: window.innerWidth,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
probe.remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
console.log('WINDOW RESTORE DIAGNOSTIC:', JSON.stringify(value));
|
||||||
|
expect(value.paddingLeft).toBeGreaterThanOrEqual(58);
|
||||||
|
expect(value.width).toBe(480);
|
||||||
|
expect(value.left).toBeGreaterThanOrEqual(value.paddingLeft);
|
||||||
|
});
|
||||||
@@ -41,18 +41,18 @@ def test_email_bulk_read_unread_checks_backend_success_before_syncing_cache():
|
|||||||
assert "_libCacheWriteBack()" in src
|
assert "_libCacheWriteBack()" in src
|
||||||
|
|
||||||
|
|
||||||
def test_email_bulk_exports_messages_and_attachments_from_selected_context():
|
def test_email_bulk_export_attachments_is_ui_only_selected_context():
|
||||||
frontend = email_library_source()
|
frontend = email_library_source()
|
||||||
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
|
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
|
||||||
export_src = _function_source("_exportSelectedBundle")
|
export_src = frontend[
|
||||||
|
frontend.index("async function _exportSelectedAttachments()"):
|
||||||
|
frontend.index("\n}\n\n// URL-suffix helper", frontend.index("async function _exportSelectedAttachments()")) + 3
|
||||||
|
]
|
||||||
payload_src = _function_source("_selectedEmailExportPayload")
|
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 "Export Attachments" in frontend
|
||||||
assert "All in view" in frontend
|
assert "All in view" in frontend
|
||||||
assert "${isEmailExport ? 'messages' : 'attachments'}-download-bulk" in export_src
|
assert "/api/email/attachments-download-bulk" in export_src
|
||||||
assert "method: 'POST'" in export_src
|
assert "method: 'POST'" in export_src
|
||||||
assert "res.blob()" in export_src
|
assert "res.blob()" in export_src
|
||||||
assert "account_id: String(em.account_id || state._libAccountId || '')" in payload_src
|
assert "account_id: String(em.account_id || state._libAccountId || '')" in payload_src
|
||||||
@@ -63,96 +63,6 @@ def test_email_bulk_exports_messages_and_attachments_from_selected_context():
|
|||||||
assert "_fixture_email_read(uid, folder, owner)" in backend
|
assert "_fixture_email_read(uid, folder, owner)" in backend
|
||||||
assert "_is_likely_signature_image_attachment(att)" 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():
|
def test_fixture_tag_filter_supports_receipt_tag_flow():
|
||||||
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
|
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
|
||||||
@@ -261,808 +171,3 @@ def test_email_compose_can_attach_gallery_images():
|
|||||||
assert "GalleryImage" in backend
|
assert "GalleryImage" in backend
|
||||||
assert "_gallery_image_path(img.filename)" in backend
|
assert "_gallery_image_path(img.filename)" in backend
|
||||||
assert "_stage_compose_file" 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
|
|
||||||
|
|||||||
@@ -1,438 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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);
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user