This commit is contained in:
Matyas Gosztonyi 2026-08-04 14:15:59 +02:00 committed by GitHub
commit 096473941d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 1083 additions and 25 deletions

View file

@ -379,16 +379,33 @@ 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:
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 +413,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 +1829,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 +4063,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),
@ -3804,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)
@ -3811,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)
@ -3826,14 +4112,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 +4145,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)."""

View file

@ -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 = {}) {
<select class="memory-sort-select" id="email-lib-folder" style="flex:1;min-width:0;text-overflow:ellipsis;">
<option value="INBOX">Inbox</option>
</select>
<button type="button" class="memory-toolbar-btn email-folder-create-btn" id="email-lib-new-folder-btn" title="New folder" aria-label="New folder">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;"><path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7l-2-2H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2Z"/><path d="M12 10v6"/><path d="M9 13h6"/></svg>
</button>
<button type="button" class="memory-toolbar-btn email-folder-rename-btn" id="email-lib-rename-folder-btn" title="Rename folder" aria-label="Rename selected folder" disabled>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>
</button>
<button type="button" class="memory-toolbar-btn email-folder-delete-btn" id="email-lib-delete-folder-btn" title="Delete folder" aria-label="Delete selected folder" disabled>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;"><path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M19 6l-1 14H6L5 6"/></svg>
</button>
<!-- Hidden native select kept as the source of truth all
existing change handlers still fire via the custom picker
dispatching 'change' on it. -->
@ -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]));

View file

@ -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;

View file

@ -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