diff --git a/routes/email_routes.py b/routes/email_routes.py index 7c3af62e2..c8728af1b 100644 --- a/routes/email_routes.py +++ b/routes/email_routes.py @@ -23,6 +23,8 @@ import smtplib import json import re import html +import io +import zipfile from html.parser import HTMLParser as _HTMLParser import logging import uuid @@ -33,7 +35,7 @@ from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart from fastapi import APIRouter, Query, UploadFile, File, BackgroundTasks, HTTPException, Depends, Request -from fastapi.responses import FileResponse +from fastapi.responses import FileResponse, StreamingResponse from src.constants import DATA_DIR from src.llm_core import llm_call_async @@ -65,6 +67,14 @@ ODYSSEUS_MAIL_ORIGIN = "odysseus-ui" EMAIL_READ_ATTACHMENT_VERSION = 2 +def _safe_attachment_zip_name(name: str, fallback: str) -> str: + """Return a zip entry filename without path traversal or empty names.""" + base = Path(str(name or "")).name.strip() or fallback + base = re.sub(r"[\x00-\x1f\x7f]+", "_", base) + base = base.replace("/", "_").replace("\\", "_").strip(". ") or fallback + return base[:180] or fallback + + def _coerce_port(value, default): """Coerce a user-supplied port to int. @@ -2530,6 +2540,59 @@ def setup_email_routes(): logger.error(f"Failed to download attachment {uid}/{index}: {e}") return {"error": "Mail operation failed"} + @router.get("/attachments-download/{uid}") + async def download_all_attachments(uid: str, folder: str = Query("INBOX"), account_id: str | None = Query(None), owner: str = Depends(require_owner)): + """Download all visible attachments for an email as a zip archive.""" + try: + with _imap(account_id, owner=owner) as conn: + conn.select(_q(folder), readonly=True) + status, msg_data = _imap_uid_fetch(conn, uid, "(RFC822)") + if status != "OK": + raise HTTPException(status_code=404, detail="Email not found") + raw = msg_data[0][1] + msg = email_mod.message_from_bytes(raw) + attachments = [ + att for att in _list_attachments_from_msg(msg) + if not _is_likely_signature_image_attachment(att) + ] + if not attachments: + raise HTTPException(status_code=404, detail="No downloadable attachments") + + target_dir = attachment_extract_dir(folder, uid) + zip_buf = io.BytesIO() + used_names: dict[str, int] = {} + with zipfile.ZipFile(zip_buf, "w", compression=zipfile.ZIP_DEFLATED) as zf: + for att in attachments: + idx = att.get("index") + if idx is None: + continue + filepath = _extract_attachment_to_disk(msg, int(idx), target_dir) + if not filepath or not Path(filepath).exists(): + continue + fallback = f"attachment-{idx}" + arcname = _safe_attachment_zip_name(att.get("filename") or Path(filepath).name, fallback) + stem = Path(arcname).stem + suffix = Path(arcname).suffix + seen = used_names.get(arcname, 0) + used_names[arcname] = seen + 1 + if seen: + arcname = f"{stem}-{seen + 1}{suffix}" + zf.write(str(filepath), arcname) + zip_buf.seek(0) + if not zip_buf.getbuffer().nbytes: + raise HTTPException(status_code=404, detail="No downloadable attachments") + zip_name = _safe_attachment_zip_name(f"email-{uid}-attachments.zip", "attachments.zip") + return StreamingResponse( + zip_buf, + media_type="application/zip", + headers={"Content-Disposition": f'attachment; filename="{zip_name}"'}, + ) + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to download attachments zip {uid}: {e}") + raise HTTPException(status_code=500, detail="Mail operation failed") + @router.get("/inline-image/{uid}") async def inline_image( uid: str, diff --git a/static/js/emailLibrary.js b/static/js/emailLibrary.js index c240bf3a5..da705c6f3 100644 --- a/static/js/emailLibrary.js +++ b/static/js/emailLibrary.js @@ -5284,6 +5284,63 @@ function _wireAttachmentHandlers(reader, folder) { // a ReferenceError when this fn is called from contexts that don't have // _isMobileUA in scope (e.g. _openEmailAsTab, _openEmailWindow). const _isMobileUA = /Android|iPhone|iPad|iPod/i.test(navigator.userAgent); + reader.querySelectorAll('.email-attachments-download-all').forEach(btn => { + if (btn.dataset.wired === '1') return; + btn.dataset.wired = '1'; + btn.addEventListener('click', async (ev) => { + ev.stopPropagation(); + ev.preventDefault(); + if (btn.dataset.downloading === '1') return; + const uid = btn.dataset.attUid; + const sourceFolder = btn.dataset.attFolder || useFolder; + const count = Number(btn.dataset.attCount || 0); + if (!uid) return; + const originalHtml = btn.innerHTML; + const originalTitle = btn.title; + btn.dataset.downloading = '1'; + btn.classList.add('is-loading'); + try { + const sp = window.spinnerModule || (await import('./spinner.js')).default; + const wp = sp.createWhirlpool(12); + wp.element.style.margin = '0'; + btn.textContent = ''; + btn.appendChild(wp.element); + const label = document.createElement('span'); + label.textContent = 'All'; + btn.appendChild(label); + } catch (_) { + btn.textContent = 'All...'; + } + try { + const url = `${API_BASE}/api/email/attachments-download/${encodeURIComponent(uid)}?folder=${encodeURIComponent(sourceFolder)}${_acct()}`; + const res = await fetch(url, { credentials: 'same-origin' }); + if (!res.ok) { + const msg = await res.text().catch(() => ''); + console.error('attachments zip download failed', res.status, msg); + location.href = url; + return; + } + const blob = await res.blob(); + const blobUrl = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = blobUrl; + a.download = `email-${uid}-attachments.zip`; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(blobUrl), 1000); + try { uiModule.showToast && uiModule.showToast(`Downloading ${count || 'all'} attachments`); } catch (_) {} + } catch (e) { + console.error('attachments zip download error', e); + try { const { showError } = await import('./ui.js'); showError('Could not download attachments'); } catch (_) {} + } finally { + delete btn.dataset.downloading; + btn.classList.remove('is-loading'); + btn.title = originalTitle; + btn.innerHTML = originalHtml; + } + }); + }); reader.querySelectorAll('.email-attachment-open').forEach(openBtn => { if (openBtn.dataset.wired === '1') return; openBtn.dataset.wired = '1'; @@ -5487,11 +5544,15 @@ function _buildAttsHtmlFor(uid, data) { ? `Thread attachments (${related.length})` : `Hidden inline attachments (${hidden.length})`; const startCollapsed = !visible.length && !related.length; + const downloadAllBtn = visible.length > 4 + ? `` + : ''; return ( `
` + '
' + '' + `${label}` + + downloadAllBtn + '' + '
' + visibleSection diff --git a/static/style.css b/static/style.css index c3869f596..33751e7ed 100644 --- a/static/style.css +++ b/static/style.css @@ -30897,6 +30897,39 @@ button .spinner-whirlpool { cursor: pointer; user-select: none; letter-spacing: 0.4px; } +.email-attachments-download-all { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 4px; + height: 22px; + padding: 0 8px; + border-radius: 999px; + border: 1px solid color-mix(in srgb, var(--accent-primary, var(--red)) 35%, transparent); + background: color-mix(in srgb, var(--accent-primary, var(--red)) 9%, transparent); + color: var(--accent-primary, var(--red)); + font: inherit; + font-size: 10px; + font-weight: 700; + text-transform: none; + letter-spacing: 0; + cursor: pointer; + flex: 0 0 auto; +} +.email-attachments-download-all:hover { + background: color-mix(in srgb, var(--accent-primary, var(--red)) 18%, transparent); + border-color: color-mix(in srgb, var(--accent-primary, var(--red)) 60%, transparent); +} +.email-attachments-download-all.is-loading { + opacity: 0.82; + pointer-events: none; +} +.email-attachments-download-all .spinner-whirlpool, +.email-attachments-download-all .ai-spinner-whirlpool { + width: 12px !important; + height: 12px !important; + margin: 0 !important; +} .email-reader-atts-wrap > .email-reader-atts { border-bottom: none !important; }