mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-07-15 12:58:04 +00:00
Add bulk email attachment downloads
This commit is contained in:
+64
-1
@@ -23,6 +23,8 @@ import smtplib
|
|||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
import html
|
import html
|
||||||
|
import io
|
||||||
|
import zipfile
|
||||||
from html.parser import HTMLParser as _HTMLParser
|
from html.parser import HTMLParser as _HTMLParser
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
@@ -33,7 +35,7 @@ from email.mime.text import MIMEText
|
|||||||
from email.mime.multipart import MIMEMultipart
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
|
||||||
from fastapi import APIRouter, Query, UploadFile, File, BackgroundTasks, HTTPException, Depends, Request
|
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.constants import DATA_DIR
|
||||||
|
|
||||||
from src.llm_core import llm_call_async
|
from src.llm_core import llm_call_async
|
||||||
@@ -65,6 +67,14 @@ ODYSSEUS_MAIL_ORIGIN = "odysseus-ui"
|
|||||||
EMAIL_READ_ATTACHMENT_VERSION = 2
|
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):
|
def _coerce_port(value, default):
|
||||||
"""Coerce a user-supplied port to int.
|
"""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}")
|
logger.error(f"Failed to download attachment {uid}/{index}: {e}")
|
||||||
return {"error": "Mail operation failed"}
|
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}")
|
@router.get("/inline-image/{uid}")
|
||||||
async def inline_image(
|
async def inline_image(
|
||||||
uid: str,
|
uid: str,
|
||||||
|
|||||||
@@ -5284,6 +5284,63 @@ function _wireAttachmentHandlers(reader, folder) {
|
|||||||
// a ReferenceError when this fn is called from contexts that don't have
|
// a ReferenceError when this fn is called from contexts that don't have
|
||||||
// _isMobileUA in scope (e.g. _openEmailAsTab, _openEmailWindow).
|
// _isMobileUA in scope (e.g. _openEmailAsTab, _openEmailWindow).
|
||||||
const _isMobileUA = /Android|iPhone|iPad|iPod/i.test(navigator.userAgent);
|
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 => {
|
reader.querySelectorAll('.email-attachment-open').forEach(openBtn => {
|
||||||
if (openBtn.dataset.wired === '1') return;
|
if (openBtn.dataset.wired === '1') return;
|
||||||
openBtn.dataset.wired = '1';
|
openBtn.dataset.wired = '1';
|
||||||
@@ -5487,11 +5544,15 @@ function _buildAttsHtmlFor(uid, data) {
|
|||||||
? `Thread attachments (${related.length})`
|
? `Thread attachments (${related.length})`
|
||||||
: `Hidden inline attachments (${hidden.length})`;
|
: `Hidden inline attachments (${hidden.length})`;
|
||||||
const startCollapsed = !visible.length && !related.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 (
|
return (
|
||||||
`<div class="email-reader-atts-wrap${startCollapsed ? ' collapsed' : ''}">`
|
`<div class="email-reader-atts-wrap${startCollapsed ? ' collapsed' : ''}">`
|
||||||
+ '<div class="email-reader-atts-header email-summary-toggle" role="button" tabindex="0">'
|
+ '<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>'
|
+ '<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>`
|
+ `<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>'
|
+ '<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>'
|
+ '</div>'
|
||||||
+ visibleSection
|
+ visibleSection
|
||||||
|
|||||||
@@ -30897,6 +30897,39 @@ button .spinner-whirlpool {
|
|||||||
cursor: pointer; user-select: none;
|
cursor: pointer; user-select: none;
|
||||||
letter-spacing: 0.4px;
|
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 {
|
.email-reader-atts-wrap > .email-reader-atts {
|
||||||
border-bottom: none !important;
|
border-bottom: none !important;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user