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)