From 986c3db1a705d3de075c21238d327a60e581441f Mon Sep 17 00:00:00 2001 From: Matyas Fenyves <16389204+uhhgoat@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:20:22 +0200 Subject: [PATCH 1/3] feat(email): add manual folder management --- routes/email_routes.py | 344 ++++++++++++++++++- static/js/emailLibrary.js | 272 ++++++++++++++- static/style.css | 6 + tests/test_email_folder_management.py | 463 ++++++++++++++++++++++++++ 4 files changed, 1064 insertions(+), 21 deletions(-) create mode 100644 tests/test_email_folder_management.py diff --git a/routes/email_routes.py b/routes/email_routes.py index 3c8e407bd..2b9120d04 100644 --- a/routes/email_routes.py +++ b/routes/email_routes.py @@ -385,10 +385,16 @@ def _folder_name_from_list_line(line) -> str | None: return match.group(1) or match.group(2) +def _imap_status_ok(status) -> bool: + if isinstance(status, bytes): + status = status.decode(errors="replace") + return str(status).strip().upper() == "OK" + + def _list_imap_folders(conn) -> tuple[list, list[str]]: try: status, folders = conn.list() - if status != "OK" or not folders: + if not _imap_status_ok(status) or not folders: return [], [] names = [name for name in (_folder_name_from_list_line(f) for f in folders) if name] return folders, names @@ -396,6 +402,246 @@ def _list_imap_folders(conn) -> tuple[list, list[str]]: return [], [] +_INVALID_NEW_FOLDER_RE = re.compile(r"[\x00\r\n]") + + +def _validate_new_mail_folder_name(value) -> tuple[str | None, str | None]: + folder = str(value or "").strip() + if not folder: + return None, "Folder name is required" + if folder.casefold() == "__scheduled__": + return None, "Folder name is reserved" + if len(folder) > 255: + return None, "Folder name is too long" + if _INVALID_NEW_FOLDER_RE.search(folder): + return None, "Folder name contains invalid characters" + return folder, None + + +def _imap_response_text(data) -> str: + parts = [] + for item in data or []: + if isinstance(item, bytes): + parts.append(item.decode(errors="replace")) + elif item is not None: + parts.append(str(item)) + return " ".join(parts).strip() + + +def _email_folder_role_from_name(name: str) -> str: + lower = (name or "").strip().lower() + if not lower: + return "" + if lower == "__scheduled__": + return "scheduled" + tokens = [token for token in re.split(r"[^a-z0-9]+", lower) if token] + text = " ".join(tokens) + token_set = set(tokens) + if text == "inbox": + return "inbox" + if "sent" in token_set: + return "sent" + if "starred" in token_set or "flagged" in token_set: + return "starred" + if "draft" in token_set or "drafts" in token_set: + return "drafts" + if text == "all mail" or "archive" in token_set or "archives" in token_set: + return "archive" + if "spam" in token_set or "junk" in token_set: + return "junk" + if "trash" in token_set or "bin" in token_set or "deleted" in token_set: + return "trash" + return "" + + +def _email_folder_role_from_list_line(line) -> str: + decoded = line.decode(errors="replace") if isinstance(line, bytes) else str(line) + lower = decoded.lower() + if "\\noselect" in lower: + return "system" + if "\\sent" in lower: + return "sent" + if "\\draft" in lower: + return "drafts" + if "\\trash" in lower: + return "trash" + if "\\junk" in lower: + return "junk" + if "\\archive" in lower or "\\all" in lower: + return "archive" + if "\\flagged" in lower: + return "starred" + return "" + + +def _protected_email_folder_error(role: str, action: str = "managed") -> str: + if role == "scheduled": + return "Scheduled is a virtual folder" + if role == "inbox": + return f"INBOX cannot be {action}" + return f"Only custom folders can be {action}" + + +def _resolve_manageable_imap_folder(conn, folder_name: str, action: str = "managed") -> tuple[str | None, str | None]: + folder, err = _validate_new_mail_folder_name(folder_name) + if err: + return None, err + + requested = folder.casefold() + folders, names = _list_imap_folders(conn) + matched = None + matched_line = None + for raw in folders: + name = _folder_name_from_list_line(raw) + if name and name.casefold() == requested: + matched = name + matched_line = raw + break + if matched is None: + for name in names: + if name.casefold() == requested: + matched = name + break + if matched is None: + return None, "Folder not found" + + role = _email_folder_role_from_name(matched) + if not role and matched_line is not None: + role = _email_folder_role_from_list_line(matched_line) + if role: + return None, _protected_email_folder_error(role, action) + return matched, None + + +def _parse_imap_message_count(data) -> int | None: + for item in data or []: + if isinstance(item, bytes): + text = item.decode(errors="replace") + elif item is None: + continue + else: + text = str(item) + match = re.search(r"\bMESSAGES\s+(\d+)\b", text, re.IGNORECASE) + if match: + return int(match.group(1)) + stripped = text.strip() + if stripped.isdigit(): + return int(stripped) + return None + + +def _mail_folder_message_count(conn, folder_name: str) -> int | None: + try: + status, data = conn.status(_q(folder_name), "(MESSAGES)") + if _imap_status_ok(status): + count = _parse_imap_message_count(data) + if count is not None: + return count + except Exception: + logger.debug("Failed to STATUS email folder %r before delete", folder_name, exc_info=True) + + selected = False + try: + status, data = conn.select(_q(folder_name), readonly=True) + if _imap_status_ok(status): + selected = True + return _parse_imap_message_count(data) + except Exception: + logger.debug("Failed to SELECT email folder %r before delete", folder_name, exc_info=True) + finally: + if selected: + try: + conn.close() + except Exception: + logger.debug("Failed to close email folder %r after delete count", folder_name, exc_info=True) + return None + + +def _create_imap_folder(conn, folder_name: str) -> tuple[bool, str]: + folder, err = _validate_new_mail_folder_name(folder_name) + if err: + return False, err + if _email_folder_role_from_name(folder): + return False, "Folder name is reserved" + + _, names = _list_imap_folders(conn) + existing = {name.casefold() for name in names} + if folder.casefold() in existing: + return False, "Folder already exists" + + status, data = conn.create(_q(folder)) + if not _imap_status_ok(status): + msg = _imap_response_text(data) + if "exist" in msg.lower(): + return False, "Folder already exists" + return False, msg or "Failed to create folder" + + try: + conn.subscribe(_q(folder)) + except Exception: + logger.debug("Failed to subscribe newly created email folder %r", folder, exc_info=True) + + return True, folder + + +def _rename_imap_folder(conn, folder_name: str, new_folder_name: str) -> tuple[bool, dict]: + folder, err = _resolve_manageable_imap_folder(conn, folder_name, action="renamed") + if err: + return False, {"error": err} + + new_folder, err = _validate_new_mail_folder_name(new_folder_name) + if err: + return False, {"error": err} + if _email_folder_role_from_name(new_folder): + return False, {"error": "Folder name is reserved"} + if new_folder == folder: + return False, {"error": "Folder name is unchanged"} + + _, names = _list_imap_folders(conn) + existing = {name.casefold() for name in names if name.casefold() != folder.casefold()} + if new_folder.casefold() in existing: + return False, {"error": "Folder already exists"} + + status, data = conn.rename(_q(folder), _q(new_folder)) + if not _imap_status_ok(status): + msg = _imap_response_text(data) + if "exist" in msg.lower(): + return False, {"error": "Folder already exists"} + return False, {"error": msg or "Failed to rename folder"} + + try: + conn.subscribe(_q(new_folder)) + except Exception: + logger.debug("Failed to subscribe renamed email folder %r", new_folder, exc_info=True) + + return True, {"old_folder": folder, "folder": new_folder} + + +def _delete_imap_folder(conn, folder_name: str, confirm_nonempty: bool = False) -> tuple[bool, dict]: + folder, err = _resolve_manageable_imap_folder(conn, folder_name, action="deleted") + if err: + return False, {"error": err} + + count = _mail_folder_message_count(conn, folder) + if (count is None or count > 0) and not confirm_nonempty: + return False, { + "error": "Folder is not empty", + "folder": folder, + "message_count": count, + "needs_confirmation": True, + } + + status, data = conn.delete(_q(folder)) + if not _imap_status_ok(status): + return False, { + "error": _imap_response_text(data) or "Failed to delete folder", + "folder": folder, + "message_count": count, + } + + return True, {"folder": folder, "message_count": count} + + def _resolve_mail_folder(conn, preferred: str, role: str = "") -> str: """Resolve provider-specific names such as Gmail's [Gmail]/Bin/Spam.""" folders, names = _list_imap_folders(conn) @@ -1572,6 +1818,17 @@ def setup_email_routes(): for k in list(_FOLDER_CACHE.keys())[:-16]: _FOLDER_CACHE.pop(k, None) + def _invalidate_folder_cache(account_id=None, owner=None): + if account_id is None and owner is None: + _FOLDER_CACHE.clear() + return + for k in list(_FOLDER_CACHE.keys()): + k_acct = k[0] if len(k) > 0 else "" + k_owner = k[1] if len(k) > 1 else "" + if (account_id is None or k_acct == (account_id or "")) and \ + (owner is None or k_owner == (owner or "")): + _FOLDER_CACHE.pop(k, None) + def _invalidate_list_cache(account_id=None, folder=None): """Drop list cache entries that the caller's mutation may have stale-ed. @@ -3795,6 +4052,20 @@ def setup_email_routes(): logger.error(f"Failed to move email {uid} to {dest}: {e}") return {"success": False, "error": "Mail operation failed"} + @router.get("/folders/status") + async def folder_status(folder: str = Query(...), account_id: str | None = Query(None), owner: str = Depends(require_owner)): + """Return deletion metadata for a user-managed IMAP folder.""" + try: + with _imap(account_id, owner=owner) as conn: + resolved, err = _resolve_manageable_imap_folder(conn, folder) + if err: + return {"success": False, "error": err} + count = _mail_folder_message_count(conn, resolved) + return {"success": True, "folder": resolved, "message_count": count} + except Exception as e: + logger.error(f"folder_status failed for {folder!r}: {e}") + return {"success": False, "error": "Mail operation failed"} + @router.get("/folders") async def list_folders( account_id: str | None = Query(None), @@ -3826,14 +4097,7 @@ def setup_email_routes(): def _list_folders_sync(): with _imap(account_id, owner=owner) as conn: - status, folders = conn.list() - result = [] - for f in folders or []: - decoded = f.decode() if isinstance(f, bytes) else f - match = re.search(r'"([^"]*)"$|(\S+)$', decoded) - if match: - name = match.group(1) or match.group(2) - result.append(name) + status, result = _list_imap_folders(conn) return { "folders": result, "sync": { @@ -3866,6 +4130,68 @@ def setup_email_routes(): logger.error(f"list_folders failed: {e}") return {"folders": [], "error": "Mail operation failed"} + @router.post("/folders") + async def create_folder(payload: dict, account_id: str | None = Query(None), owner: str = Depends(require_owner)): + """Create a user-requested IMAP folder.""" + folder, err = _validate_new_mail_folder_name((payload or {}).get("folder") or (payload or {}).get("name")) + if err: + return {"success": False, "error": err} + try: + with _imap(account_id, owner=owner) as conn: + ok, result = _create_imap_folder(conn, folder) + if not ok: + return {"success": False, "error": result} + _, names = _list_imap_folders(conn) + if result not in names: + names.append(result) + _invalidate_list_cache(account_id) + _invalidate_folder_cache(account_id, owner) + return {"success": True, "folder": result, "folders": names} + except Exception as e: + logger.error(f"create_folder failed for {folder!r}: {e}") + return {"success": False, "error": "Mail operation failed"} + + @router.patch("/folders") + async def rename_folder(payload: dict, account_id: str | None = Query(None), owner: str = Depends(require_owner)): + """Rename a user-managed IMAP folder.""" + folder = (payload or {}).get("folder") or (payload or {}).get("old_name") + new_folder = (payload or {}).get("new_folder") or (payload or {}).get("new_name") or (payload or {}).get("name") + try: + with _imap(account_id, owner=owner) as conn: + ok, result = _rename_imap_folder(conn, folder, new_folder) + if not ok: + return {"success": False, **result} + _, names = _list_imap_folders(conn) + if result["folder"] not in names: + names.append(result["folder"]) + _invalidate_list_cache(account_id) + _invalidate_folder_cache(account_id, owner) + return {"success": True, **result, "folders": names} + except Exception as e: + logger.error(f"rename_folder failed for {folder!r} -> {new_folder!r}: {e}") + return {"success": False, "error": "Mail operation failed"} + + @router.delete("/folders") + async def delete_folder( + folder: str = Query(...), + confirm_nonempty: bool = Query(False), + account_id: str | None = Query(None), + owner: str = Depends(require_owner), + ): + """Delete a user-managed IMAP folder.""" + try: + with _imap(account_id, owner=owner) as conn: + ok, result = _delete_imap_folder(conn, folder, confirm_nonempty=confirm_nonempty) + if not ok: + return {"success": False, **result} + _, names = _list_imap_folders(conn) + _invalidate_list_cache(account_id) + _invalidate_folder_cache(account_id, owner) + return {"success": True, **result, "folders": names} + except Exception as e: + logger.error(f"delete_folder failed for {folder!r}: {e}") + return {"success": False, "error": "Mail operation failed"} + @router.post("/mark-answered/{uid}") async def mark_answered(uid: str, folder: str = Query("INBOX"), account_id: str | None = Query(None), owner: str = Depends(require_owner)): """Mark an email as answered (set \\Answered flag).""" diff --git a/static/js/emailLibrary.js b/static/js/emailLibrary.js index 6a0d3e294..c62728202 100644 --- a/static/js/emailLibrary.js +++ b/static/js/emailLibrary.js @@ -4,7 +4,7 @@ */ import spinnerModule from './spinner.js'; -import { styledConfirm, showToast, emptyStateIcon } from './ui.js'; +import { styledConfirm, styledPrompt, showToast, emptyStateIcon } from './ui.js'; import { folderDisplayName, sortedFolders } from './emailInbox.js?v=20260722emailfastindex1'; import settingsModule from './settings.js'; import * as Modals from './modalManager.js'; @@ -1752,6 +1752,61 @@ function _rememberedEmailAccountId() { } } +function _acctStart() { + return state._libAccountId ? `?account_id=${encodeURIComponent(state._libAccountId)}` : ''; +} + +function _emailFolderApiUrl(path = '', params = {}) { + const query = new URLSearchParams(); + if (state._libAccountId) query.set('account_id', state._libAccountId); + Object.entries(params || {}).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== '') query.set(key, String(value)); + }); + const qs = query.toString(); + return `${API_BASE}/api/email/folders${path}${qs ? `?${qs}` : ''}`; +} + +function _emailFolderRole(folder) { + const f = String(folder || '').trim().toLowerCase(); + if (!f) return ''; + if (f === '__scheduled__') return 'scheduled'; + const tokens = f.split(/[^a-z0-9]+/).filter(Boolean); + const tokenSet = new Set(tokens); + const text = tokens.join(' '); + if (text === 'inbox') return 'inbox'; + if (tokenSet.has('sent')) return 'sent'; + if (tokenSet.has('starred') || tokenSet.has('flagged')) return 'starred'; + if (tokenSet.has('draft') || tokenSet.has('drafts')) return 'drafts'; + if (text === 'all mail' || tokenSet.has('archive') || tokenSet.has('archives')) return 'archive'; + if (tokenSet.has('spam') || tokenSet.has('junk')) return 'junk'; + if (tokenSet.has('trash') || tokenSet.has('bin') || tokenSet.has('deleted')) return 'trash'; + return ''; +} + +function _isCustomEmailFolder(folder) { + return Boolean(String(folder || '').trim()) && !_emailFolderRole(folder); +} + +function _syncFolderManagementControls() { + const renameBtn = document.getElementById('email-lib-rename-folder-btn'); + const deleteBtn = document.getElementById('email-lib-delete-folder-btn'); + const canManage = _isCustomEmailFolder(state._libFolder); + const controls = [renameBtn, deleteBtn].filter(Boolean); + for (const btn of controls) { + btn.disabled = !canManage; + } + if (renameBtn) { + renameBtn.title = canManage + ? `Rename folder "${folderDisplayName(state._libFolder)}"` + : 'Built-in folders cannot be renamed'; + } + if (deleteBtn) { + deleteBtn.title = canManage + ? `Delete folder "${folderDisplayName(state._libFolder)}"` + : 'Built-in folders cannot be deleted'; + } +} + // Per-(account, folder, filter, attachments) cache of the most recent // first-page list response. Lets reopen-after-close paint the previous // list instantly while the network refresh runs behind it — the modal @@ -2381,6 +2436,15 @@ export function openEmailLibrary(opts = {}) { + + + @@ -2579,8 +2643,12 @@ export function openEmailLibrary(opts = {}) { document.getElementById('email-lib-folder').addEventListener('change', (e) => { state._libFolder = e.target.value; + _syncFolderManagementControls(); _loadEmailsFresh(); }); + document.getElementById('email-lib-new-folder-btn')?.addEventListener('click', _createFolderFromLibrary); + document.getElementById('email-lib-rename-folder-btn')?.addEventListener('click', _renameFolderFromLibrary); + document.getElementById('email-lib-delete-folder-btn')?.addEventListener('click', _deleteFolderFromLibrary); document.getElementById('email-lib-filter').addEventListener('change', (e) => { state._libFilter = e.target.value; _syncUnreadWindowGlow(); @@ -3286,12 +3354,24 @@ async function _loadFolders({ resetMissing = false, live = false } = {}) { if (f === state._libFolder) opt.selected = true; sel.appendChild(opt); } - if (priority.length > 0 && others.length > 0) { + if (priority.length > 0) { const sep = document.createElement('option'); sep.disabled = true; sep.textContent = '─────────'; sel.appendChild(sep); } + // Scheduled (special virtual folder) + const schedOpt = document.createElement('option'); + schedOpt.value = '__scheduled__'; + schedOpt.textContent = 'Scheduled'; + if (state._libFolder === '__scheduled__') schedOpt.selected = true; + sel.appendChild(schedOpt); + if (others.length > 0) { + const sep3 = document.createElement('option'); + sep3.disabled = true; + sep3.textContent = '─────────'; + sel.appendChild(sep3); + } for (const f of others) { const opt = document.createElement('option'); opt.value = f; @@ -3299,20 +3379,188 @@ async function _loadFolders({ resetMissing = false, live = false } = {}) { if (f === state._libFolder) opt.selected = true; sel.appendChild(opt); } - // Scheduled (special virtual folder) - const sep2 = document.createElement('option'); - sep2.disabled = true; - sep2.textContent = '─────────'; - sel.appendChild(sep2); - const schedOpt = document.createElement('option'); - schedOpt.value = '__scheduled__'; - schedOpt.textContent = 'Scheduled'; - if (state._libFolder === '__scheduled__') schedOpt.selected = true; - sel.appendChild(schedOpt); sel.value = state._libFolder; + _syncFolderManagementControls(); } catch (e) {} } +async function _createFolderFromLibrary() { + const requested = await styledPrompt('Folder name', { + title: 'New folder', + placeholder: 'Clients', + confirmText: 'Create', + maxLength: 255, + }); + const folder = String(requested || '').trim(); + if (!folder) return; + + const btn = document.getElementById('email-lib-new-folder-btn'); + btn?.setAttribute('disabled', 'disabled'); + try { + const res = await fetch(`${API_BASE}/api/email/folders${_acctStart()}`, { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: folder }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok || data.success === false) { + showToast(data.error || 'Failed to create folder'); + return; + } + + state._libFolder = data.folder || folder; + state._libOffset = 0; + _libListCache.clear(); + if (Array.isArray(data.folders)) state._libFolders = data.folders; + await _loadFolders(); + const sel = document.getElementById('email-lib-folder'); + if (sel) sel.value = state._libFolder; + _syncFolderManagementControls(); + await _loadEmails({ force: true, useCache: false }); + showToast(`Created folder "${folderDisplayName(state._libFolder)}"`); + } catch (err) { + console.error('Create email folder failed:', err); + showToast('Failed to create folder'); + } finally { + btn?.removeAttribute('disabled'); + } +} + +async function _deleteFolderRequest(folder, confirmNonempty = false) { + const res = await fetch(_emailFolderApiUrl('', { + folder, + confirm_nonempty: confirmNonempty ? 'true' : 'false', + }), { + method: 'DELETE', + credentials: 'same-origin', + }); + const data = await res.json().catch(() => ({})); + if (!res.ok || data.success === false) return { ok: false, data }; + return { ok: true, data }; +} + +async function _renameFolderFromLibrary() { + const folder = String(state._libFolder || '').trim(); + if (!_isCustomEmailFolder(folder)) { + showToast('Only custom folders can be renamed'); + _syncFolderManagementControls(); + return; + } + + const requested = await styledPrompt('Folder name', { + title: 'Rename folder', + defaultValue: folder, + confirmText: 'Rename', + maxLength: 255, + }); + const newFolder = String(requested || '').trim(); + if (!newFolder || newFolder === folder) return; + + const btn = document.getElementById('email-lib-rename-folder-btn'); + btn?.setAttribute('disabled', 'disabled'); + try { + const res = await fetch(_emailFolderApiUrl(), { + method: 'PATCH', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ folder, name: newFolder }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok || data.success === false) { + showToast(data.error || 'Failed to rename folder'); + return; + } + + const renamed = data.folder || newFolder; + state._libFolder = renamed; + state._libOffset = 0; + _libListCache.clear(); + if (Array.isArray(data.folders)) state._libFolders = data.folders; + await _loadFolders(); + const sel = document.getElementById('email-lib-folder'); + if (sel) sel.value = state._libFolder; + _syncFolderManagementControls(); + await _loadEmails({ force: true, useCache: false }); + showToast(`Renamed folder to "${folderDisplayName(renamed)}"`); + } catch (err) { + console.error('Rename email folder failed:', err); + showToast('Failed to rename folder'); + } finally { + btn?.removeAttribute('disabled'); + _syncFolderManagementControls(); + } +} + +async function _deleteFolderFromLibrary() { + const folder = String(state._libFolder || '').trim(); + if (!_isCustomEmailFolder(folder)) { + showToast('Only custom folders can be deleted'); + _syncFolderManagementControls(); + return; + } + + const btn = document.getElementById('email-lib-delete-folder-btn'); + btn?.setAttribute('disabled', 'disabled'); + try { + const statusRes = await fetch(_emailFolderApiUrl('/status', { folder }), { + credentials: 'same-origin', + }); + const statusData = await statusRes.json().catch(() => ({})); + if (!statusRes.ok || statusData.success === false) { + showToast(statusData.error || 'Failed to check folder'); + return; + } + + const resolvedFolder = statusData.folder || folder; + const display = folderDisplayName(resolvedFolder); + const rawCount = statusData.message_count; + const count = rawCount === null || rawCount === undefined ? null : Number(rawCount); + const countKnown = Number.isFinite(count); + const message = countKnown && count === 0 + ? `Permanently delete empty folder "${display}"?` + : `PERMANENTLY DELETE folder "${display}"?\n\n${countKnown ? `It contains ${count} email${count === 1 ? '' : 's'}.` : 'Odysseus could not confirm whether it is empty.'} Deleting this folder can permanently delete every message inside it.`; + const ok = await styledConfirm(message, { + confirmText: 'Delete Folder', + cancelText: 'Cancel', + danger: true, + }); + if (!ok) return; + + let result = await _deleteFolderRequest(resolvedFolder, !(countKnown && count === 0)); + if (!result.ok && result.data?.needs_confirmation) { + const confirmAgain = await styledConfirm( + `PERMANENTLY DELETE folder "${display}"?\n\nIt is not empty. Deleting this folder can permanently delete every message inside it.`, + { confirmText: 'Delete Folder', cancelText: 'Cancel', danger: true }, + ); + if (!confirmAgain) return; + result = await _deleteFolderRequest(resolvedFolder, true); + } + if (!result.ok) { + showToast(result.data?.error || 'Failed to delete folder'); + return; + } + + if (Array.isArray(result.data.folders)) state._libFolders = result.data.folders; + const folders = Array.isArray(state._libFolders) ? state._libFolders : []; + state._libFolder = folders.includes('INBOX') ? 'INBOX' : (folders[0] || 'INBOX'); + state._libOffset = 0; + _libListCache.clear(); + await _loadFolders({ resetMissing: true }); + const sel = document.getElementById('email-lib-folder'); + if (sel) sel.value = state._libFolder; + _syncFolderManagementControls(); + await _loadEmails({ force: true, useCache: false }); + showToast(`Deleted folder "${display}"`); + } catch (err) { + console.error('Delete email folder failed:', err); + showToast('Failed to delete folder'); + } finally { + btn?.removeAttribute('disabled'); + _syncFolderManagementControls(); + } +} + function _crossFolderCandidates() { const available = Array.isArray(state._libFolders) ? state._libFolders.filter(Boolean) : []; const lower = new Map(available.map(f => [String(f).toLowerCase(), f])); diff --git a/static/style.css b/static/style.css index 73fdbcd5b..8afbe1a33 100644 --- a/static/style.css +++ b/static/style.css @@ -37627,12 +37627,18 @@ button.cal-add-btn.cal-add-btn-text.cal-add-btn-sm:hover .cal-add-label { } /* Select moved into the dropdown row — dock it to the right and match the selects' vertical nudge (.memory-sort-select has top:3px). */ +.memory-category-filters .email-folder-create-btn, +.memory-category-filters .email-folder-rename-btn, +.memory-category-filters .email-folder-delete-btn, .memory-category-filters .email-filter-select-btn, .memory-category-filters .email-filter-refresh-btn { position: relative; top: 3px; flex-shrink: 0; } +.memory-category-filters .email-folder-create-btn, +.memory-category-filters .email-folder-rename-btn, +.memory-category-filters .email-folder-delete-btn, .memory-category-filters .email-filter-refresh-btn { display: inline-flex; align-items: center; diff --git a/tests/test_email_folder_management.py b/tests/test_email_folder_management.py new file mode 100644 index 000000000..71d7aa992 --- /dev/null +++ b/tests/test_email_folder_management.py @@ -0,0 +1,463 @@ +from contextlib import contextmanager +from pathlib import Path +import re + +import pytest + + +def _route_endpoint(router, path: str, method: str): + method = method.upper() + for route in router.routes: + if route.path == path and method in getattr(route, "methods", set()): + return route.endpoint + raise AssertionError(f"route not found: {method} {path}") + + +def _fake_mailbox_name(mailbox): + text = mailbox.decode(errors="replace") if isinstance(mailbox, bytes) else str(mailbox) + if len(text) >= 2 and text[0] == '"' and text[-1] == '"': + return text[1:-1].replace(r"\"", '"').replace(r"\\", "\\") + return text + + +def _fake_list_line_name(line): + decoded = line.decode(errors="replace") if isinstance(line, bytes) else str(line) + match = re.search(r'"([^"]*)"\s*$|(\S+)\s*$', decoded) + return (match.group(1) or match.group(2)) if match else "" + + +class FakeCreateFolderConn: + def __init__( + self, + folders=None, + create_status="OK", + create_data=None, + rename_status="OK", + rename_data=None, + delete_status="OK", + delete_data=None, + message_counts=None, + ): + self.folders = list(folders or ['(\\HasNoChildren) "/" "INBOX"']) + self.create_status = create_status + self.create_data = create_data if create_data is not None else [b"CREATE completed"] + self.rename_status = rename_status + self.rename_data = rename_data if rename_data is not None else [b"RENAME completed"] + self.delete_status = delete_status + self.delete_data = delete_data if delete_data is not None else [b"DELETE completed"] + self.message_counts = {str(k).casefold(): int(v) for k, v in (message_counts or {}).items()} + self.calls = [] + + def list(self): + self.calls.append(("list",)) + return "OK", list(self.folders) + + def create(self, mailbox): + self.calls.append(("create", mailbox)) + if self.create_status == "OK": + self.folders.append(f'(\\HasNoChildren) "/" {mailbox}') + return self.create_status, self.create_data + + def subscribe(self, mailbox): + self.calls.append(("subscribe", mailbox)) + return "OK", [b"SUBSCRIBE completed"] + + def rename(self, old_mailbox, new_mailbox): + self.calls.append(("rename", old_mailbox, new_mailbox)) + if self.rename_status == "OK": + old_name = _fake_mailbox_name(old_mailbox).casefold() + new_line = f'(\\HasNoChildren) "/" {new_mailbox}' + self.folders = [ + new_line if _fake_list_line_name(line).casefold() == old_name else line + for line in self.folders + ] + return self.rename_status, self.rename_data + + def status(self, mailbox, items): + self.calls.append(("status", mailbox, items)) + folder = _fake_mailbox_name(mailbox) + count = self.message_counts.get(folder.casefold(), 0) + return "OK", [f"{mailbox} (MESSAGES {count})".encode()] + + def select(self, mailbox, readonly=False): + self.calls.append(("select", mailbox, readonly)) + folder = _fake_mailbox_name(mailbox) + count = self.message_counts.get(folder.casefold(), 0) + return "OK", [str(count).encode()] + + def close(self): + self.calls.append(("close",)) + return "OK", [b"CLOSE completed"] + + def delete(self, mailbox): + self.calls.append(("delete", mailbox)) + if self.delete_status == "OK": + target = _fake_mailbox_name(mailbox).casefold() + self.folders = [ + line for line in self.folders + if _fake_list_line_name(line).casefold() != target + ] + return self.delete_status, self.delete_data + + +def test_create_imap_folder_quotes_and_subscribes(): + import routes.email_routes as email_routes + + conn = FakeCreateFolderConn() + + ok, folder = email_routes._create_imap_folder(conn, "Project Mail") + + assert ok is True + assert folder == "Project Mail" + assert ("create", '"Project Mail"') in conn.calls + assert ("subscribe", '"Project Mail"') in conn.calls + + +def test_create_imap_folder_rejects_duplicate_without_create_call(): + import routes.email_routes as email_routes + + conn = FakeCreateFolderConn( + folders=[ + '(\\HasNoChildren) "/" "INBOX"', + '(\\HasNoChildren) "/" "Project Mail"', + ] + ) + + ok, error = email_routes._create_imap_folder(conn, "project mail") + + assert ok is False + assert error == "Folder already exists" + assert not any(call[0] == "create" for call in conn.calls) + + +@pytest.mark.parametrize("name", ["", " ", "bad\nname", "bad\rname", "bad\x00name", "__scheduled__", "__SCHEDULED__", "INBOX"]) +def test_create_imap_folder_rejects_invalid_names(name): + import routes.email_routes as email_routes + + conn = FakeCreateFolderConn() + + ok, error = email_routes._create_imap_folder(conn, name) + + assert ok is False + assert error + assert not any(call[0] == "create" for call in conn.calls) + + +def test_rename_imap_folder_quotes_and_subscribes_new_folder(): + import routes.email_routes as email_routes + + conn = FakeCreateFolderConn( + folders=[ + '(\\HasNoChildren) "/" "INBOX"', + '(\\HasNoChildren) "/" "Project Mail"', + ] + ) + + ok, result = email_routes._rename_imap_folder(conn, "project mail", "Client Mail") + + assert ok is True + assert result == {"old_folder": "Project Mail", "folder": "Client Mail"} + assert ("rename", '"Project Mail"', '"Client Mail"') in conn.calls + assert ("subscribe", '"Client Mail"') in conn.calls + assert "Client Mail" in [email_routes._folder_name_from_list_line(f) for f in conn.folders] + + +def test_rename_imap_folder_rejects_duplicate_without_rename_call(): + import routes.email_routes as email_routes + + conn = FakeCreateFolderConn( + folders=[ + '(\\HasNoChildren) "/" "INBOX"', + '(\\HasNoChildren) "/" "Project Mail"', + '(\\HasNoChildren) "/" "Client Mail"', + ] + ) + + ok, result = email_routes._rename_imap_folder(conn, "Project Mail", "client mail") + + assert ok is False + assert result["error"] == "Folder already exists" + assert not any(call[0] == "rename" for call in conn.calls) + + +def test_rename_imap_folder_rejects_protected_source_but_allows_presentation_name(): + import routes.email_routes as email_routes + + protected = FakeCreateFolderConn(folders=['(\\HasNoChildren) "/" "INBOX"']) + + ok, result = email_routes._rename_imap_folder(protected, "INBOX", "Clients") + + assert ok is False + assert result["error"] + assert not any(call[0] == "rename" for call in protected.calls) + + custom = FakeCreateFolderConn( + folders=[ + '(\\HasNoChildren) "/" "INBOX"', + '(\\HasNoChildren) "/" "Presentation"', + ] + ) + + ok, result = email_routes._rename_imap_folder(custom, "Presentation", "Presentation Notes") + + assert ok is True + assert result["folder"] == "Presentation Notes" + + +def test_delete_imap_folder_deletes_empty_custom_folder(): + import routes.email_routes as email_routes + + conn = FakeCreateFolderConn( + folders=[ + '(\\HasNoChildren) "/" "INBOX"', + '(\\HasNoChildren) "/" "Project Mail"', + ], + message_counts={"Project Mail": 0}, + ) + + ok, result = email_routes._delete_imap_folder(conn, "project mail") + + assert ok is True + assert result["folder"] == "Project Mail" + assert result["message_count"] == 0 + assert ("delete", '"Project Mail"') in conn.calls + assert "Project Mail" not in [email_routes._folder_name_from_list_line(f) for f in conn.folders] + + +def test_delete_imap_folder_requires_confirmation_for_nonempty_folder(): + import routes.email_routes as email_routes + + conn = FakeCreateFolderConn( + folders=[ + '(\\HasNoChildren) "/" "INBOX"', + '(\\HasNoChildren) "/" "Clients"', + ], + message_counts={"Clients": 2}, + ) + + ok, result = email_routes._delete_imap_folder(conn, "Clients") + + assert ok is False + assert result["needs_confirmation"] is True + assert result["message_count"] == 2 + assert not any(call[0] == "delete" for call in conn.calls) + + ok, result = email_routes._delete_imap_folder(conn, "Clients", confirm_nonempty=True) + + assert ok is True + assert result["folder"] == "Clients" + assert ("delete", '"Clients"') in conn.calls + + +@pytest.mark.parametrize( + "name,line", + [ + ("INBOX", '(\\HasNoChildren) "/" "INBOX"'), + ("Sent", '(\\Sent \\HasNoChildren) "/" "Sent"'), + ("Drafts", '(\\Drafts \\HasNoChildren) "/" "Drafts"'), + ("Archive", '(\\Archive \\HasNoChildren) "/" "Archive"'), + ("Spam", '(\\Junk \\HasNoChildren) "/" "Spam"'), + ("Trash", '(\\Trash \\HasNoChildren) "/" "Trash"'), + ], +) +def test_delete_imap_folder_rejects_protected_folders(name, line): + import routes.email_routes as email_routes + + conn = FakeCreateFolderConn(folders=[line]) + + ok, result = email_routes._delete_imap_folder(conn, name, confirm_nonempty=True) + + assert ok is False + assert result["error"] + assert not any(call[0] == "delete" for call in conn.calls) + + +@pytest.mark.asyncio +async def test_create_folder_route_creates_and_returns_updated_list(monkeypatch): + import routes.email_routes as email_routes + + conn = FakeCreateFolderConn() + seen = [] + + @contextmanager + def fake_imap(account_id=None, owner=""): + seen.append((account_id, owner)) + yield conn + + monkeypatch.setattr(email_routes, "_imap", fake_imap) + router = email_routes.setup_email_routes() + create_folder = _route_endpoint(router, "/api/email/folders", "POST") + + result = await create_folder({"name": "Clients"}, account_id="acct-alice", owner="alice") + + assert result["success"] is True + assert result["folder"] == "Clients" + assert result["folders"] == ["INBOX", "Clients"] + assert seen == [("acct-alice", "alice")] + assert ("create", '"Clients"') in conn.calls + + +@pytest.mark.asyncio +async def test_create_folder_route_invalidates_stale_folder_cache(monkeypatch): + import routes.email_routes as email_routes + + conn = FakeCreateFolderConn() + + @contextmanager + def fake_imap(account_id=None, owner=""): + yield conn + + monkeypatch.setattr(email_routes, "_imap", fake_imap) + router = email_routes.setup_email_routes() + list_folders = _route_endpoint(router, "/api/email/folders", "GET") + create_folder = _route_endpoint(router, "/api/email/folders", "POST") + + before = await list_folders(account_id="acct-alice", owner="alice") + assert before["folders"] == ["INBOX"] + + result = await create_folder({"name": "Clients"}, account_id="acct-alice", owner="alice") + assert result["success"] is True + + after = await list_folders(account_id="acct-alice", owner="alice") + assert after["sync"]["source"] == "imap" + assert after["folders"] == ["INBOX", "Clients"] + + +@pytest.mark.asyncio +async def test_folder_status_route_returns_message_count(monkeypatch): + import routes.email_routes as email_routes + + conn = FakeCreateFolderConn( + folders=[ + '(\\HasNoChildren) "/" "INBOX"', + '(\\HasNoChildren) "/" "Clients"', + ], + message_counts={"Clients": 3}, + ) + + @contextmanager + def fake_imap(account_id=None, owner=""): + yield conn + + monkeypatch.setattr(email_routes, "_imap", fake_imap) + router = email_routes.setup_email_routes() + folder_status = _route_endpoint(router, "/api/email/folders/status", "GET") + + result = await folder_status(folder="Clients", account_id="acct-alice", owner="alice") + + assert result == {"success": True, "folder": "Clients", "message_count": 3} + assert ("status", '"Clients"', "(MESSAGES)") in conn.calls + + +@pytest.mark.asyncio +async def test_rename_folder_route_renames_and_returns_updated_list(monkeypatch): + import routes.email_routes as email_routes + + conn = FakeCreateFolderConn( + folders=[ + '(\\HasNoChildren) "/" "INBOX"', + '(\\HasNoChildren) "/" "Clients"', + ] + ) + seen = [] + + @contextmanager + def fake_imap(account_id=None, owner=""): + seen.append((account_id, owner)) + yield conn + + monkeypatch.setattr(email_routes, "_imap", fake_imap) + router = email_routes.setup_email_routes() + rename_folder = _route_endpoint(router, "/api/email/folders", "PATCH") + + result = await rename_folder({"folder": "Clients", "name": "Customers"}, account_id="acct-alice", owner="alice") + + assert result["success"] is True + assert result["old_folder"] == "Clients" + assert result["folder"] == "Customers" + assert result["folders"] == ["INBOX", "Customers"] + assert seen == [("acct-alice", "alice")] + assert ("rename", '"Clients"', '"Customers"') in conn.calls + + +@pytest.mark.asyncio +async def test_delete_folder_route_deletes_and_returns_updated_list(monkeypatch): + import routes.email_routes as email_routes + + conn = FakeCreateFolderConn( + folders=[ + '(\\HasNoChildren) "/" "INBOX"', + '(\\HasNoChildren) "/" "Clients"', + ], + message_counts={"Clients": 0}, + ) + seen = [] + + @contextmanager + def fake_imap(account_id=None, owner=""): + seen.append((account_id, owner)) + yield conn + + monkeypatch.setattr(email_routes, "_imap", fake_imap) + router = email_routes.setup_email_routes() + delete_folder = _route_endpoint(router, "/api/email/folders", "DELETE") + + result = await delete_folder(folder="Clients", confirm_nonempty=False, account_id="acct-alice", owner="alice") + + assert result["success"] is True + assert result["folder"] == "Clients" + assert result["folders"] == ["INBOX"] + assert seen == [("acct-alice", "alice")] + assert ("delete", '"Clients"') in conn.calls + + +@pytest.mark.asyncio +async def test_delete_folder_route_requires_nonempty_confirmation(monkeypatch): + import routes.email_routes as email_routes + + conn = FakeCreateFolderConn( + folders=[ + '(\\HasNoChildren) "/" "INBOX"', + '(\\HasNoChildren) "/" "Clients"', + ], + message_counts={"Clients": 1}, + ) + + @contextmanager + def fake_imap(account_id=None, owner=""): + yield conn + + monkeypatch.setattr(email_routes, "_imap", fake_imap) + router = email_routes.setup_email_routes() + delete_folder = _route_endpoint(router, "/api/email/folders", "DELETE") + + result = await delete_folder(folder="Clients", confirm_nonempty=False, account_id="acct-alice", owner="alice") + + assert result["success"] is False + assert result["needs_confirmation"] is True + assert result["message_count"] == 1 + assert not any(call[0] == "delete" for call in conn.calls) + + +def test_email_library_exposes_folder_creation_control(): + src = Path("static/js/emailLibrary.js").read_text(encoding="utf-8") + css = Path("static/style.css").read_text(encoding="utf-8") + + assert "email-lib-new-folder-btn" in src + assert "styledPrompt('Folder name'" in src + assert "/api/email/folders${_acctStart()}" in src + assert "method: 'POST'" in src + assert "email-lib-delete-folder-btn" in src + assert "email-lib-rename-folder-btn" in src + assert 'id="email-lib-rename-folder-btn" title="Rename folder" aria-label="Rename selected folder" disabled' in src + assert 'id="email-lib-delete-folder-btn" title="Delete folder" aria-label="Delete selected folder" disabled' in src + assert "_isCustomEmailFolder" in src + assert "_emailFolderApiUrl('/status'" in src + assert "method: 'PATCH'" in src + assert "method: 'DELETE'" in src + assert "PERMANENTLY DELETE folder" in src + assert "permanently delete every message inside it" in src + assert src.index("schedOpt.value = '__scheduled__'") < src.index("for (const f of others)") + assert ".email-folder-create-btn" in css + assert ".email-folder-rename-btn" in css + assert ".email-folder-delete-btn" in css From 7a900c976a71593dd40aa7fd73428605b70cb6a1 Mon Sep 17 00:00:00 2001 From: Matyas Fenyves <16389204+uhhgoat@users.noreply.github.com> Date: Fri, 31 Jul 2026 11:05:37 +0200 Subject: [PATCH 2/3] fix(email): parse IMAP folder names without regex --- routes/email_routes.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/routes/email_routes.py b/routes/email_routes.py index 2b9120d04..a4a70e492 100644 --- a/routes/email_routes.py +++ b/routes/email_routes.py @@ -379,10 +379,21 @@ def _record_email_received_events(owner: str, account_id: str | None, folder: st def _folder_name_from_list_line(line) -> str | None: decoded = line.decode() if isinstance(line, bytes) else str(line) - match = re.search(r'"([^"]*)"\s*$|(\S+)\s*$', decoded) - if not match: + decoded = decoded.rstrip() + if not decoded: return None - return match.group(1) or match.group(2) + if decoded.endswith('"'): + escaped = False + for idx in range(len(decoded) - 2, -1, -1): + ch = decoded[idx] + if ch == '"' and not escaped: + value = decoded[idx + 1 : -1] + return value.replace(r"\\", "\\").replace(r"\"", '"') + escaped = ch == "\\" and not escaped + if ch != "\\": + escaped = False + parts = decoded.split() + return parts[-1] if parts else None def _imap_status_ok(status) -> bool: From aa954ba8231fbef86d4379d8a0244d5c4db7f808 Mon Sep 17 00:00:00 2001 From: Matyas Fenyves <16389204+uhhgoat@users.noreply.github.com> Date: Fri, 31 Jul 2026 11:11:39 +0200 Subject: [PATCH 3/3] fix(email): normalize folder cache query defaults --- routes/email_routes.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/routes/email_routes.py b/routes/email_routes.py index a4a70e492..3563acd9b 100644 --- a/routes/email_routes.py +++ b/routes/email_routes.py @@ -4086,6 +4086,10 @@ def setup_email_routes(): """List IMAP folders.""" if _fixture_email_enabled(): return {"folders": ["INBOX", "Archive", "Sent"], "sync": {"source": "fixture"}} + try: + cached_only_enabled = bool(int(cached_only or 0)) + except (TypeError, ValueError): + cached_only_enabled = False cached = _folder_cache_get(account_id, owner) if cached is not None: payload = dict(cached) @@ -4093,7 +4097,7 @@ def setup_email_routes(): sync_meta["source"] = "folder_cache" payload["sync"] = sync_meta return payload - if cached_only: + if cached_only_enabled: stale = _folder_cache_get_stale(account_id, owner) if stale: payload = dict(stale)