mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-08-05 02:45:28 +00:00
Merge aa954ba823 into 20e7fc0164
This commit is contained in:
commit
096473941d
4 changed files with 1083 additions and 25 deletions
|
|
@ -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)."""
|
||||
|
|
|
|||
|
|
@ -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]));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
463
tests/test_email_folder_management.py
Normal file
463
tests/test_email_folder_management.py
Normal 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
|
||||
Loading…
Add table
Reference in a new issue