Add bulk email attachment downloads

This commit is contained in:
pewdiepie-archdaemon
2026-07-03 01:15:40 +00:00
parent 9718f7874b
commit 0fc98c4a17
3 changed files with 158 additions and 1 deletions
+64 -1
View File
@@ -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,
+61
View File
@@ -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
+33
View File
@@ -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;
}