diff --git a/routes/email_routes.py b/routes/email_routes.py index 3c8e407bd..56c3d6913 100644 --- a/routes/email_routes.py +++ b/routes/email_routes.py @@ -396,11 +396,8 @@ def _list_imap_folders(conn) -> tuple[list, list[str]]: return [], [] -def _resolve_mail_folder(conn, preferred: str, role: str = "") -> str: - """Resolve provider-specific names such as Gmail's [Gmail]/Bin/Spam.""" +def _find_mail_folder(conn, role: str) -> str | None: folders, names = _list_imap_folders(conn) - if preferred and preferred in names: - return preferred role_flags = { "trash": ("\\Trash",), "archive": ("\\Archive", "\\All"), @@ -408,7 +405,8 @@ def _resolve_mail_folder(conn, preferred: str, role: str = "") -> str: }.get(role, ()) for f in folders: decoded = f.decode() if isinstance(f, bytes) else str(f) - if any(flag in decoded for flag in role_flags): + decoded_lower = decoded.lower() + if any(flag.lower() in decoded_lower for flag in role_flags): name = _folder_name_from_list_line(f) if name: return name @@ -422,9 +420,47 @@ def _resolve_mail_folder(conn, preferred: str, role: str = "") -> str: found = lower_map.get(candidate.lower()) if found: return found + return None + + +def _resolve_mail_folder(conn, preferred: str, role: str = "") -> str: + """Resolve provider-specific names such as Gmail's [Gmail]/Bin/Spam.""" + _, names = _list_imap_folders(conn) + if preferred and preferred in names: + return preferred + found = _find_mail_folder(conn, role) + if found: + return found return preferred +def _create_archive_mail_folder(conn, folder_name: str = "Archive") -> tuple[bool, dict]: + folder = str(folder_name or "Archive").strip() or "Archive" + if folder.casefold() != "archive": + return False, {"error": "Archive setup can only create a folder named Archive"} + existing = _find_mail_folder(conn, "archive") + if existing: + return True, {"folder": existing, "created": False} + + status, data = conn.create(_q("Archive")) + if status != "OK": + msg = " ".join( + item.decode(errors="replace") if isinstance(item, bytes) else str(item) + for item in (data or []) + if item is not None + ).strip() + if "exist" in msg.lower(): + return True, {"folder": "Archive", "created": False} + return False, {"error": msg or "Failed to create Archive folder"} + + try: + conn.subscribe(_q("Archive")) + except Exception: + logger.debug("Failed to subscribe newly created Archive folder", exc_info=True) + + return True, {"folder": "Archive", "created": True} + + def _folder_role_from_name(name: str) -> str: lower = (name or "").lower() if "trash" in lower or "bin" in lower or "deleted" in lower: @@ -1572,6 +1608,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. @@ -3665,11 +3712,19 @@ def setup_email_routes(): try: with _imap(account_id, owner=owner) as conn: conn.select(_q(folder)) - if not _move_email_message(conn, uid, "Archive", role="archive"): + archive_folder = _find_mail_folder(conn, "archive") + if not archive_folder: + return { + "success": False, + "needs_archive_folder": True, + "suggested_folder": "Archive", + "error": "No archive folder found", + } + if not _move_email_message(conn, uid, archive_folder, role="archive"): return {"success": False, "error": "Email not found"} _email_index_delete(owner, account_id, folder, uid) _invalidate_list_cache(account_id) - return {"success": True} + return {"success": True, "folder": archive_folder} except Exception as e: logger.error(f"Failed to archive email {uid}: {e}") return {"success": False, "error": "Mail operation failed"} @@ -3799,19 +3854,30 @@ def setup_email_routes(): async def list_folders( account_id: str | None = Query(None), cached_only: int = Query(0), + refresh: bool = Query(False), owner: str = Depends(require_owner), ): """List IMAP folders.""" if _fixture_email_enabled(): return {"folders": ["INBOX", "Archive", "Sent"], "sync": {"source": "fixture"}} - cached = _folder_cache_get(account_id, owner) + try: + cached_only_enabled = bool(int(cached_only or 0)) + except (TypeError, ValueError): + cached_only_enabled = False + if isinstance(refresh, str): + refresh_enabled = refresh.strip().lower() in {"1", "true", "yes", "on"} + elif isinstance(refresh, (bool, int)): + refresh_enabled = bool(refresh) + else: + refresh_enabled = False + cached = None if refresh_enabled else _folder_cache_get(account_id, owner) if cached is not None: payload = dict(cached) sync_meta = dict(payload.get("sync") or {}) 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) @@ -3866,6 +3932,25 @@ def setup_email_routes(): logger.error(f"list_folders failed: {e}") return {"folders": [], "error": "Mail operation failed"} + @router.post("/archive-folder") + async def create_archive_folder(payload: dict | None = None, account_id: str | None = Query(None), owner: str = Depends(require_owner)): + """Create the account's explicit Archive folder after user confirmation.""" + folder_name = (payload or {}).get("folder") or (payload or {}).get("name") or "Archive" + try: + with _imap(account_id, owner=owner) as conn: + ok, result = _create_archive_mail_folder(conn, folder_name) + 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"create_archive_folder failed for {folder_name!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/emailInbox.js b/static/js/emailInbox.js index 605a5ff61..cd6ab637d 100644 --- a/static/js/emailInbox.js +++ b/static/js/emailInbox.js @@ -14,6 +14,7 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js'; const API_BASE = window.location.origin; const _acct = () => emailAccountQuery('&'); +const _acctStart = () => emailAccountQuery('?'); const _emailSetupHint = () => '
Setup: Settings › Integrations
'; @@ -454,7 +455,7 @@ export function sortedFolders(folders) { if (f.includes('sent')) return 'sent'; if (f.includes('starred') || f.includes('flagged')) return 'starred'; if (f.includes('draft')) return 'drafts'; - if (f.includes('all mail') || f.includes('archive')) return 'archive'; + if (isArchiveFolder(f)) return 'archive'; if (f.includes('spam') || f.includes('junk')) return 'junk'; if (f.includes('trash') || f.includes('bin') || f.includes('deleted')) return 'trash'; return ''; @@ -470,6 +471,11 @@ export function sortedFolders(folders) { return { priority: roleOrder.map(role => found.get(role)).filter(Boolean), others }; } +export function isArchiveFolder(folder) { + const f = String(folder || '').toLowerCase(); + return f.includes('all mail') || f.includes('archive'); +} + export function folderDisplayName(folder) { const raw = String(folder || ''); const f = raw.toLowerCase(); @@ -688,7 +694,7 @@ function _createEmailItem(em) { }); // Swipe left to archive (mobile). Mirrors sidebar-layout.js swipe pattern. - if ('ontouchstart' in window) { + if ('ontouchstart' in window && !isArchiveFolder(_currentFolder)) { let startX = 0, startY = 0, dx = 0, dy = 0, swiping = false, swiped = false; const HORIZ_THRESHOLD = 70; // px to trigger archive const VERT_CANCEL = 30; // px vertical motion cancels swipe (treat as scroll) @@ -730,7 +736,7 @@ function _createEmailItem(em) { item.style.transform = 'translateX(-100%)'; item.style.opacity = '0'; setTimeout(() => { - _archiveEmail(em); + _archiveEmail(em, item); delete item.dataset.swipeBlock; }, 200); } else { @@ -1070,9 +1076,11 @@ function _showEmailMenu(em, anchor, itemEl) { const actions = [ { label: 'Open', icon: _replyIcon, action: () => _openEmail(em, itemEl) }, { label: 'Remind to reply', icon: _bellIcon, submenu: 'remind' }, - { label: 'Archive', icon: _archiveIcon, action: () => _archiveEmail(em) }, - { label: 'Delete', icon: _deleteIcon, danger: true, action: () => _deleteEmail(em) }, ]; + if (!isArchiveFolder(_currentFolder)) { + actions.push({ label: 'Archive', icon: _archiveIcon, action: () => _archiveEmail(em, itemEl) }); + } + actions.push({ label: 'Delete', icon: _deleteIcon, danger: true, action: () => _deleteEmail(em) }); for (const a of actions) { const menuItem = document.createElement('div'); @@ -1210,13 +1218,96 @@ async function _createReplyReminder(em, dueDate) { } } -async function _archiveEmail(em) { +let _archiveSetupPromptPromise = null; + +async function _ensureArchiveFolderForSidebar(suggestedFolder = 'Archive') { + if (_archiveSetupPromptPromise) return _archiveSetupPromptPromise; + _archiveSetupPromptPromise = (async () => { + const { styledConfirm, showToast } = await import('./ui.js'); + const folderName = String(suggestedFolder || 'Archive').trim() || 'Archive'; + const ok = await styledConfirm( + `No Archive folder was found for this account. Create one named ${folderName}?`, + { confirmText: 'Create Archive', cancelText: 'Cancel' }, + ); + if (!ok) return false; + + try { + const res = await fetch(`${API_BASE}/api/email/archive-folder${_acctStart()}`, { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: folderName }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok || data.success === false) { + showToast(data.error || 'Failed to create Archive folder'); + return false; + } + await _loadFolders(); + showToast(data.created ? 'Created Archive folder' : 'Archive folder is ready'); + return true; + } catch (err) { + console.error('Create Archive folder failed:', err); + showToast('Failed to create Archive folder'); + return false; + } + })(); try { - await fetch(`${API_BASE}/api/email/archive/${em.uid}?folder=${encodeURIComponent(_currentFolder)}${_acct()}`, { method: 'POST' }); + return await _archiveSetupPromptPromise; + } finally { + _archiveSetupPromptPromise = null; + } +} + +async function _archiveEmailWithFallback(em) { + const archiveOnce = async () => { + const res = await fetch(`${API_BASE}/api/email/archive/${em.uid}?folder=${encodeURIComponent(_currentFolder)}${_acct()}`, { method: 'POST' }); + const data = await res.json().catch(() => ({})); + if (!res.ok || data.success === false) return { success: false, ...data }; + return { success: true, ...data }; + }; + + const first = await archiveOnce(); + if (first.success) return first; + + if (first.needs_archive_folder) { + const ready = await _ensureArchiveFolderForSidebar(first.suggested_folder || 'Archive'); + if (!ready) return { success: false, canceled: true }; + const retry = await archiveOnce(); + if (retry.success) return retry; + const { showToast } = await import('./ui.js'); + showToast(retry.error || 'Failed to archive email'); + return retry; + } + + const { showToast } = await import('./ui.js'); + showToast(first.error || 'Failed to archive email'); + return first; +} + +function _restoreArchiveSwipeItem(itemEl) { + if (!itemEl) return; + itemEl.style.transition = 'transform 0.2s ease, opacity 0.2s ease'; + itemEl.style.transform = ''; + itemEl.style.opacity = ''; + itemEl.style.background = ''; + delete itemEl.dataset.swipeBlock; +} + +async function _archiveEmail(em, itemEl = null) { + try { + const result = await _archiveEmailWithFallback(em); + if (!result.success) { + _restoreArchiveSwipeItem(itemEl); + return false; + } _emails = _emails.filter(e => e.uid !== em.uid); _renderList(); + return true; } catch (e) { console.error('Failed to archive:', e); + _restoreArchiveSwipeItem(itemEl); + return false; } } diff --git a/static/js/emailLibrary.js b/static/js/emailLibrary.js index 6a0d3e294..95ee6ddc7 100644 --- a/static/js/emailLibrary.js +++ b/static/js/emailLibrary.js @@ -5,7 +5,7 @@ import spinnerModule from './spinner.js'; import { styledConfirm, showToast, emptyStateIcon } from './ui.js'; -import { folderDisplayName, sortedFolders } from './emailInbox.js?v=20260722emailfastindex1'; +import { folderDisplayName, isArchiveFolder, sortedFolders } from './emailInbox.js?v=20260722emailfastindex1'; import settingsModule from './settings.js'; import * as Modals from './modalManager.js'; import { topPortalZ } from './toolWindowZOrder.js'; @@ -1752,6 +1752,10 @@ function _rememberedEmailAccountId() { } } +function _acctStart() { + return state._libAccountId ? `?account_id=${encodeURIComponent(state._libAccountId)}` : ''; +} + // 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 @@ -1963,6 +1967,7 @@ async function _refreshEmailLibraryFromUi(btn = null) { // mid-refresh. `force: true` adds the cache-buster so the server's // 8s list cache is bypassed for an actually-fresh result. try { + await _loadFolders({ resetMissing: true, refresh: true }); await _loadEmails({ force: true }); } finally { btn?.classList.remove('email-lib-refreshing'); @@ -2933,7 +2938,7 @@ export function openEmailLibrary(opts = {}) { // otherwise waited on `/accounts` before even trying the cheap indexed list. (async () => { await _loadAccounts(); - _loadFolders(); + _loadFolders({ refresh: true }); _loadEmailReminderBellVisibility(); if (!fastAccountAtOpen || fastAccountAtOpen !== (state._libAccountId || '')) { _loadEmailsWhenChatIdle(); @@ -3249,13 +3254,14 @@ function _snapEmailModalToLeftSidebar(modal) { return true; } -async function _loadFolders({ resetMissing = false, live = false } = {}) { +async function _loadFolders({ resetMissing = false, live = false, refresh = false } = {}) { const seq = ++_libFolderSeq; const accountAtStart = state._libAccountId || ''; try { const res = await fetch(emailApiUrl('/api/email/folders', { account_id: accountAtStart || undefined, - cached_only: live ? undefined : 1, + cached_only: (live || refresh) ? undefined : 1, + refresh: refresh ? 1 : undefined, })); let data = await res.json(); if (seq !== _libFolderSeq || accountAtStart !== (state._libAccountId || '')) return; @@ -3313,6 +3319,71 @@ async function _loadFolders({ resetMissing = false, live = false } = {}) { } catch (e) {} } +let _archiveSetupPromptPromise = null; + +async function _ensureArchiveFolderForAccount(suggestedFolder = 'Archive') { + if (_archiveSetupPromptPromise) return _archiveSetupPromptPromise; + _archiveSetupPromptPromise = (async () => { + const folderName = String(suggestedFolder || 'Archive').trim() || 'Archive'; + const ok = await styledConfirm( + `No Archive folder was found for this account. Create one named ${folderName}?`, + { confirmText: 'Create Archive', cancelText: 'Cancel' }, + ); + if (!ok) return false; + + try { + const res = await fetch(`${API_BASE}/api/email/archive-folder${_acctStart()}`, { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: folderName }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok || data.success === false) { + showToast(data.error || 'Failed to create Archive folder'); + return false; + } + if (Array.isArray(data.folders)) state._libFolders = data.folders; + await _loadFolders(); + showToast(data.created ? 'Created Archive folder' : 'Archive folder is ready'); + return true; + } catch (err) { + console.error('Create Archive folder failed:', err); + showToast('Failed to create Archive folder'); + return false; + } + })(); + try { + return await _archiveSetupPromptPromise; + } finally { + _archiveSetupPromptPromise = null; + } +} + +async function _archiveEmailWithFallback(uid, folder = state._libFolder) { + const archiveOnce = async () => { + const res = await fetch(`${API_BASE}/api/email/archive/${uid}?folder=${encodeURIComponent(folder)}${_acct()}`, { method: 'POST' }); + const data = await res.json().catch(() => ({})); + if (!res.ok || data.success === false) return { success: false, ...data }; + return { success: true, ...data }; + }; + + const first = await archiveOnce(); + if (first.success) return first; + + if (first.needs_archive_folder) { + const ready = await _ensureArchiveFolderForAccount(first.suggested_folder || 'Archive'); + if (!ready) return { success: false, canceled: true }; + const retry = await archiveOnce(); + if (retry.success) return retry; + showToast(retry.error || 'Failed to archive email'); + return retry; + } + + showToast(first.error || 'Failed to archive email'); + return first; +} + function _crossFolderCandidates() { const available = Array.isArray(state._libFolders) ? state._libFolders.filter(Boolean) : []; const lower = new Map(available.map(f => [String(f).toLowerCase(), f])); @@ -7528,9 +7599,9 @@ function _showReaderMoreMenu(em, card, reader, anchor) { icon: _archIcon, action: async () => { try { - await fetch(`${API_BASE}/api/email/archive/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' }); + const result = await _archiveEmailWithFallback(em.uid); + if (result.success) await closeAndRemove(); } catch (e) { console.error(e); } - await closeAndRemove(); }, }, { @@ -7618,6 +7689,10 @@ function _showReaderMoreMenu(em, card, reader, anchor) { }, }, ]; + if (isArchiveFolder(state._libFolder)) { + const archiveIdx = actions.findIndex(a => a.label === 'Move to Archive'); + if (archiveIdx !== -1) actions.splice(archiveIdx, 1); + } for (const a of actions) { if (a.separator) { @@ -7682,6 +7757,7 @@ function _showCardMenu(em, anchor) { const _cardBellIcon = ''; const isSentFolder = /sent/i.test(state._libFolder); + const isArchiveCurrentFolder = isArchiveFolder(state._libFolder); const _newTabIcon = ''; const actions = [ @@ -7755,17 +7831,20 @@ function _showCardMenu(em, anchor) { } }, }); - actions.push({ - label: 'Archive', - icon: _archIcon, - action: async () => { - await fetch(`${API_BASE}/api/email/archive/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' }); - await _animateEmailCardRemoval([em.uid]); - state._libEmails = state._libEmails.filter(e => String(e.uid) !== String(em.uid)); - _renderGrid(); - _libCacheWriteBack(); - }, - }); + if (!isArchiveCurrentFolder) { + actions.push({ + label: 'Archive', + icon: _archIcon, + action: async () => { + const result = await _archiveEmailWithFallback(em.uid); + if (!result.success) return; + await _animateEmailCardRemoval([em.uid]); + state._libEmails = state._libEmails.filter(e => String(e.uid) !== String(em.uid)); + _renderGrid(); + _libCacheWriteBack(); + }, + }); + } } else { actions.push({ label: em.is_flagged ? 'Unfavorite' : 'Favorite (pin to top)', @@ -7783,17 +7862,20 @@ function _showCardMenu(em, anchor) { } }, }); - actions.push({ - label: 'Archive', - icon: _archIcon, - action: async () => { - await fetch(`${API_BASE}/api/email/archive/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' }); - await _animateEmailCardRemoval([em.uid]); - state._libEmails = state._libEmails.filter(e => String(e.uid) !== String(em.uid)); - _renderGrid(); - _libCacheWriteBack(); - }, - }); + if (!isArchiveCurrentFolder) { + actions.push({ + label: 'Archive', + icon: _archIcon, + action: async () => { + const result = await _archiveEmailWithFallback(em.uid); + if (!result.success) return; + await _animateEmailCardRemoval([em.uid]); + state._libEmails = state._libEmails.filter(e => String(e.uid) !== String(em.uid)); + _renderGrid(); + _libCacheWriteBack(); + }, + }); + } } // "Select" — switch to multi-select mode with THIS email pre-selected so @@ -7941,6 +8023,8 @@ async function _bulkAction(action) { const uids = Array.from(state._selectedUids); if (uids.length === 0) return; let failedReadSync = 0; + let archiveCanceled = false; + const successfulArchiveUids = new Set(); if (action === 'delete') { const ok = await styledConfirm( `Delete ${uids.length} selected email${uids.length === 1 ? '' : 's'}?`, @@ -8001,7 +8085,15 @@ async function _bulkAction(action) { const handleOne = async (uid) => { try { if (action === 'archive') { - await fetch(`${API_BASE}/api/email/archive/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' }); + if (archiveCanceled) return; + const result = await _archiveEmailWithFallback(uid); + if (result.success) { + successfulArchiveUids.add(String(uid)); + } else if (result.canceled) { + archiveCanceled = true; + } else { + throw new Error(result.error || 'Archive failed'); + } } else if (action === 'delete') { await fetch(`${API_BASE}/api/email/delete/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'DELETE' }); } else if (action === 'done') { @@ -8061,10 +8153,13 @@ async function _bulkAction(action) { launch(); }); - if (action === 'archive' || action === 'delete') { - if (action === 'delete') { - deleteOverlays.forEach(busy => busy.remove?.()); - } + if (action === 'archive') { + const archived = uids.filter(uid => successfulArchiveUids.has(String(uid))); + if (archived.length > 0) await _animateEmailCardRemoval(archived); + const removed = new Set(archived.map(uid => String(uid))); + state._libEmails = state._libEmails.filter(e => !removed.has(String(e.uid))); + } else if (action === 'delete') { + deleteOverlays.forEach(busy => busy.remove?.()); await _animateEmailCardRemoval(uids); const removed = new Set(uids.map(uid => String(uid))); state._libEmails = state._libEmails.filter(e => !removed.has(String(e.uid))); diff --git a/tests/test_email_archive_fallback.py b/tests/test_email_archive_fallback.py new file mode 100644 index 000000000..adf7cd08b --- /dev/null +++ b/tests/test_email_archive_fallback.py @@ -0,0 +1,266 @@ +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 FakeArchiveConn: + def __init__(self, folders=None, create_status="OK", create_data=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.calls = [] + + def list(self): + self.calls.append(("list",)) + return "OK", list(self.folders) + + def select(self, mailbox): + self.calls.append(("select", mailbox)) + return "OK", [b"1"] + + 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 test_find_mail_folder_detects_archive_candidates_and_special_use_flag(): + import routes.email_routes as email_routes + + special_use = FakeArchiveConn(folders=['(\\HasNoChildren \\Archive) "/" "Old Mail"']) + gmail = FakeArchiveConn(folders=['(\\HasNoChildren) "/" "[Gmail]/All Mail"']) + + assert email_routes._find_mail_folder(special_use, "archive") == "Old Mail" + assert email_routes._find_mail_folder(gmail, "archive") == "[Gmail]/All Mail" + + +def test_find_mail_folder_returns_none_when_archive_destination_is_missing(): + import routes.email_routes as email_routes + + conn = FakeArchiveConn( + folders=[ + '(\\HasNoChildren) "/" "INBOX"', + '(\\HasNoChildren) "/" "Sent"', + ] + ) + + assert email_routes._find_mail_folder(conn, "archive") is None + + +def test_create_archive_mail_folder_creates_and_subscribes_archive(): + import routes.email_routes as email_routes + + conn = FakeArchiveConn() + + ok, result = email_routes._create_archive_mail_folder(conn) + + assert ok is True + assert result == {"folder": "Archive", "created": True} + assert ("create", '"Archive"') in conn.calls + assert ("subscribe", '"Archive"') in conn.calls + assert "Archive" in [email_routes._folder_name_from_list_line(f) for f in conn.folders] + + +def test_create_archive_mail_folder_rejects_non_archive_name(): + import routes.email_routes as email_routes + + conn = FakeArchiveConn() + + ok, result = email_routes._create_archive_mail_folder(conn, "Trash") + + assert ok is False + assert result["error"] == "Archive setup can only create a folder named Archive" + assert not any(call[0] == "create" for call in conn.calls) + + +def test_archive_route_returns_setup_response_when_archive_folder_missing(monkeypatch): + import routes.email_routes as email_routes + + conn = FakeArchiveConn() + + @contextmanager + def fake_imap(account_id=None, owner=""): + yield conn + + move_calls = [] + monkeypatch.setattr(email_routes, "_imap", fake_imap) + monkeypatch.setattr(email_routes, "_move_email_message", lambda *args, **kwargs: move_calls.append((args, kwargs)) or True) + router = email_routes.setup_email_routes() + archive_email = _route_endpoint(router, "/api/email/archive/{uid}", "POST") + + result = archive_email("42", folder="INBOX", account_id="acct-alice", owner="alice") + + assert result == { + "success": False, + "needs_archive_folder": True, + "suggested_folder": "Archive", + "error": "No archive folder found", + } + assert move_calls == [] + + +def test_archive_route_uses_existing_archive_folder(monkeypatch): + import routes.email_routes as email_routes + + conn = FakeArchiveConn( + folders=[ + '(\\HasNoChildren) "/" "INBOX"', + '(\\HasNoChildren) "/" "Archive"', + ] + ) + move_calls = [] + + @contextmanager + def fake_imap(account_id=None, owner=""): + yield conn + + def fake_move(conn_arg, uid, dest, role=""): + move_calls.append((uid, dest, role)) + return True + + monkeypatch.setattr(email_routes, "_imap", fake_imap) + monkeypatch.setattr(email_routes, "_move_email_message", fake_move) + router = email_routes.setup_email_routes() + archive_email = _route_endpoint(router, "/api/email/archive/{uid}", "POST") + + result = archive_email("42", folder="INBOX", account_id="acct-alice", owner="alice") + + assert result == {"success": True, "folder": "Archive"} + assert move_calls == [("42", "Archive", "archive")] + + +@pytest.mark.asyncio +async def test_create_archive_folder_route_returns_updated_folders(monkeypatch): + import routes.email_routes as email_routes + + conn = FakeArchiveConn() + + @contextmanager + def fake_imap(account_id=None, owner=""): + yield conn + + monkeypatch.setattr(email_routes, "_imap", fake_imap) + router = email_routes.setup_email_routes() + create_archive_folder = _route_endpoint(router, "/api/email/archive-folder", "POST") + + result = await create_archive_folder({"name": "Archive"}, account_id="acct-alice", owner="alice") + + assert result["success"] is True + assert result["folder"] == "Archive" + assert result["created"] is True + assert result["folders"] == ["INBOX", "Archive"] + + +@pytest.mark.asyncio +async def test_create_archive_folder_invalidates_stale_folder_cache(monkeypatch): + import routes.email_routes as email_routes + + conn = FakeArchiveConn() + + @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_archive_folder = _route_endpoint(router, "/api/email/archive-folder", "POST") + + before = await list_folders(account_id="acct-alice", owner="alice") + assert before["folders"] == ["INBOX"] + + result = await create_archive_folder({"name": "Archive"}, 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", "Archive"] + + +@pytest.mark.asyncio +async def test_list_folders_refresh_bypasses_stale_folder_cache(monkeypatch): + import routes.email_routes as email_routes + + conn = FakeArchiveConn() + + @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") + + before = await list_folders(account_id="acct-alice", refresh=False, owner="alice") + assert before["folders"] == ["INBOX"] + + conn.folders.append('(\\HasNoChildren) "/" "Archive"') + + cached = await list_folders(account_id="acct-alice", refresh=False, owner="alice") + assert cached["sync"]["source"] == "folder_cache" + assert cached["folders"] == ["INBOX"] + + refreshed = await list_folders(account_id="acct-alice", refresh=True, owner="alice") + assert refreshed["sync"]["source"] == "imap" + assert refreshed["folders"] == ["INBOX", "Archive"] + + +def test_email_library_handles_archive_folder_setup_flow(): + src = Path("static/js/emailLibrary.js").read_text(encoding="utf-8") + + assert "import { folderDisplayName, isArchiveFolder, sortedFolders } from './emailInbox.js" in src + assert "_archiveEmailWithFallback" in src + assert "needs_archive_folder" in src + assert "No Archive folder was found for this account. Create one named" in src + assert "/api/email/archive-folder" in src + assert "successfulArchiveUids" in src + assert "const result = await _archiveEmailWithFallback(em.uid)" in src + assert "actions.findIndex(a => a.label === 'Move to Archive')" in src + assert "const isArchiveCurrentFolder = isArchiveFolder(state._libFolder)" in src + assert "cached_only: (live || refresh) ? undefined : 1" in src + assert "refresh: refresh ? 1 : undefined" in src + assert "_loadFolders({ refresh: true })" in src + assert "_loadFolders({ resetMissing: true, refresh: true })" in src + + +def test_sidebar_inbox_archive_uses_fallback_before_local_removal(): + src = Path("static/js/emailInbox.js").read_text(encoding="utf-8") + + assert "export function isArchiveFolder(folder)" in src + assert "_archiveEmailWithFallback" in src + assert "_ensureArchiveFolderForSidebar" in src + assert "needs_archive_folder" in src + assert "/api/email/archive-folder" in src + assert "if (!isArchiveFolder(_currentFolder))" in src + assert "'ontouchstart' in window && !isArchiveFolder(_currentFolder)" in src + assert "if (!result.success)" in src + assert "_restoreArchiveSwipeItem(itemEl)" in src + assert "await fetch(`${API_BASE}/api/email/archive/${em.uid}?folder=${encodeURIComponent(_currentFolder)}${_acct()}`, { method: 'POST' });\n _emails = _emails.filter" not in src