feat(email): prompt to create archive folder

This commit is contained in:
Matyas Fenyves 2026-07-01 15:37:35 +02:00
parent 25c9e735ef
commit e12418fae1
4 changed files with 498 additions and 44 deletions

View file

@ -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)."""

View file

@ -14,6 +14,7 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
const API_BASE = window.location.origin;
const _acct = () => emailAccountQuery('&');
const _acctStart = () => emailAccountQuery('?');
const _emailSetupHint = () => '<div style="margin-top:6px;opacity:0.72;font-size:11px;">Setup: <span style="color:var(--accent,var(--red));">Settings &rsaquo; Integrations</span></div>';
@ -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;
}
}

View file

@ -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 = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>';
const isSentFolder = /sent/i.test(state._libFolder);
const isArchiveCurrentFolder = isArchiveFolder(state._libFolder);
const _newTabIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>';
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)));

View file

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