mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-07-10 12:17:11 +00:00
Add bulk email attachment downloads
This commit is contained in:
+64
-1
@@ -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,
|
||||
|
||||
@@ -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
|
||||
? `<button type="button" class="email-attachments-download-all" title="Download all attachments" data-att-uid="${_esc(uid)}" data-att-folder="${_esc(data.folder || state._libFolder || 'INBOX')}" data-att-count="${visible.length}"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><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><span>All</span></button>`
|
||||
: '';
|
||||
return (
|
||||
`<div class="email-reader-atts-wrap${startCollapsed ? ' collapsed' : ''}">`
|
||||
+ '<div class="email-reader-atts-header email-summary-toggle" role="button" tabindex="0">'
|
||||
+ '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 17.93 8.8l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>'
|
||||
+ `<span>${label}</span>`
|
||||
+ downloadAllBtn
|
||||
+ '<svg class="email-summary-chevron" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="margin-left:auto;transition:transform .15s ease;"><polyline points="6 9 12 15 18 9"/></svg>'
|
||||
+ '</div>'
|
||||
+ visibleSection
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user