From e12418fae1b3e781e2e4982149f538da97fd5f2a Mon Sep 17 00:00:00 2001
From: Matyas Fenyves <16389204+uhhgoat@users.noreply.github.com>
Date: Wed, 1 Jul 2026 15:37:35 +0200
Subject: [PATCH] feat(email): prompt to create archive folder
---
routes/email_routes.py | 76 +++++++++-
static/js/emailInbox.js | 105 +++++++++++++-
static/js/emailLibrary.js | 153 ++++++++++++++++----
tests/test_email_archive_fallback.py | 208 +++++++++++++++++++++++++++
4 files changed, 498 insertions(+), 44 deletions(-)
create mode 100644 tests/test_email_archive_fallback.py
diff --git a/routes/email_routes.py b/routes/email_routes.py
index 3c8e407bd..0a3695ffc 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:
@@ -3665,11 +3701,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"}
@@ -3866,6 +3910,24 @@ 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)
+ 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..e892e1c77 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
@@ -3313,6 +3317,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 +7597,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 +7687,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 +7755,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 +7829,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 +7860,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 +8021,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 +8083,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 +8151,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..5d197dcd4
--- /dev/null
+++ b/tests/test_email_archive_fallback.py
@@ -0,0 +1,208 @@
+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"]
+
+
+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
+
+
+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