// static/js/emailLibrary/unsubscribe.js // // The bulk-unsubscribe review flow: scan `List-Unsubscribe` headers, group // candidates by sender, and run the mailto/URL/agent-browser unsubscribe plus // the optional delete-after-unsubscribe cleanup. // // Reached from one place — the Clean action in the email-library header — and // it owns its own localStorage keys (`odysseus.email.unsubscribe*`), its own // icon set, and the `odysseus:agent-tool-output` listeners that watch an // agent-browser run finish. Nothing else in the package reads any of that, // which is why this is the one module with a single exported entry point. import spinnerModule from '../spinner.js'; import { emailApiUrl } from '../emailShared.js'; import { showToast, styledConfirm } from '../ui.js?v=20260916largetoolscroll1'; import { state } from './state.js'; import { _esc } from './utils.js'; import { _libCacheWriteBack, _renderGrid, isOpen, refreshEmailLibrary } from './index.js'; function _unsubscribeMethodLabel(method) { if (!method) return 'No unsubscribe method'; if (method.kind === 'mailto') return 'Request Unsubscribe'; if (method.kind === 'url') return 'Link Unsubscribe'; return method.target || method.kind || 'Unsubscribe'; } function _unsubscribeFolderLabel(folder) { const value = String(folder || 'INBOX').trim(); return value.toUpperCase() === 'INBOX' ? 'Inbox' : value; } function _setUnsubButtonBusy(btn, label) { if (!btn) return null; const previous = btn.innerHTML; const previousDisplay = btn.style.display; const previousAlignItems = btn.style.alignItems; const previousJustifyContent = btn.style.justifyContent; const previousGap = btn.style.gap; const previousWhiteSpace = btn.style.whiteSpace; btn.disabled = true; btn.style.display = 'inline-flex'; btn.style.alignItems = 'center'; btn.style.justifyContent = 'center'; btn.style.gap = '5px'; btn.style.whiteSpace = 'nowrap'; btn.innerHTML = ''; const sp = spinnerModule.createWhirlpool(14); sp.element.style.position = 'relative'; sp.element.style.top = '-2px'; sp.element.style.flexShrink = '0'; sp.element.style.margin = '0'; sp.element.style.display = 'inline-flex'; btn.appendChild(sp.element); const text = document.createElement('span'); text.textContent = label || 'Working'; text.className = 'email-unsub-busy-label'; text.style.whiteSpace = 'nowrap'; text.style.display = 'inline-block'; btn.appendChild(text); const restore = () => { btn.disabled = false; btn.innerHTML = previous; btn.style.display = previousDisplay; btn.style.alignItems = previousAlignItems; btn.style.justifyContent = previousJustifyContent; btn.style.gap = previousGap; btn.style.whiteSpace = previousWhiteSpace; }; restore.setLabel = (next) => { text.textContent = next || label || 'Working'; }; return restore; } const _UNSUB_EMAIL_ICON = ''; const _UNSUB_CHECK_ICON = ''; const _UNSUB_CLOSE_ICON = ''; const _UNSUB_AGENT_ICON = ''; const _UNSUB_TRASH_ICON = ''; const _UNSUB_REFRESH_ICON = ''; const _UNSUB_EXTERNAL_ICON = ''; function _setUnsubStatusBusy(statusEl, label) { if (!statusEl) return null; statusEl.classList.remove('is-error'); statusEl.classList.add('is-busy'); statusEl.innerHTML = ''; statusEl.style.display = 'inline-flex'; statusEl.style.alignItems = 'center'; statusEl.style.gap = '6px'; statusEl.style.justifyContent = 'flex-end'; statusEl.style.width = '100%'; const text = document.createElement('span'); text.textContent = label || 'Scanning…'; statusEl.appendChild(text); const sp = spinnerModule.createWhirlpool(14); sp.element.style.position = 'relative'; sp.element.style.top = '0'; sp.element.style.flexShrink = '0'; sp.element.style.margin = '0'; sp.element.style.display = 'inline-flex'; statusEl.appendChild(sp.element); return (next) => { statusEl.style.display = ''; statusEl.style.alignItems = ''; statusEl.style.gap = ''; statusEl.style.justifyContent = ''; statusEl.style.width = ''; statusEl.classList.remove('is-busy'); statusEl.textContent = next || ''; }; } function _askAgentToUnsubscribe(candidate) { const methods = Array.isArray(candidate?.methods) ? candidate.methods : []; const recommended = candidate?.recommended_method; const method = (recommended && typeof recommended === 'object') ? recommended : methods.find(m => m.kind === 'url') || methods[0] || null; const url = method?.kind === 'url' ? method.target : ''; const methodIndex = Math.max(0, methods.findIndex(m => ( m === method || (m?.kind === method?.kind && m?.target === method?.target) ))); const uid = candidate?.uid || ''; const reviewedUids = _unsubscribeCandidateUids(candidate); const folder = candidate?.folder || state._libFolder || 'INBOX'; const account = state._libAccountId || ''; const prompt = url ? `The user explicitly clicked Agent Unsubscribe for this reviewed email. Use the private_browser tool to open this exact unsubscribe URL and complete only the unsubscribe flow. Do not use web_search, web_fetch, bash, or scan_email_unsubscribes. After the page confirms a successful unsubscribe, use bulk_email action=delete on these exact reviewed UID(s) so they are not rediscovered. If the page is ambiguous or unsubscribe fails, stop and report that clearly so the user can choose Spam + delete; do not delete a different message.\n\nEmail UID(s): ${reviewedUids.join(', ') || uid}\nFolder: ${folder}\nAccount: ${account || '(default)'}\nExact unsubscribe URL: ${url}` : `The user explicitly clicked Agent Unsubscribe for this reviewed email. Use unsubscribe_email directly for the already-reviewed candidate below. Do not call scan_email_unsubscribes again.\n\nEmail UID: ${uid}\nFolder: ${folder}\nAccount: ${account || '(default)'}\nReviewed method_index: ${methodIndex}`; return _submitAgentUnsubscribePrompt(prompt); } function _submitAgentUnsubscribePrompt(prompt) { const input = document.getElementById('message') || document.getElementById('message-input'); const form = document.getElementById('chat-form'); if (!input || !form) { showToast?.('Chat composer not found'); return false; } input.value = prompt; input.dispatchEvent(new Event('input', { bubbles: true })); input.focus(); // Match the working slash-command path. The app-level submit guard can // still be settling when this is called from a modal button. setTimeout(() => { if (!document.body.contains(input) || !document.body.contains(form)) return; try { form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); } catch (err) { console.error('agent unsubscribe submit failed', err); showToast?.('Could not start agent unsubscribe'); } }, 350); return true; } function _agentUnsubscribeGroups(candidates) { const groups = new Map(); (candidates || []).forEach(candidate => { const urlMethod = (candidate?.methods || []).find(method => method?.kind === 'url'); const sender = String(candidate?.from_address || '').trim(); const key = sender ? `sender:${sender.toLowerCase()}` : `uid:${_unsubscribeCandidateUids(candidate).join(',')}`; let group = groups.get(key); if (!group) { group = { subject: String(candidate?.subject || '(no subject)'), sender, uids: [], url: String(urlMethod?.target || ''), subjects: [], }; groups.set(key, group); } _unsubscribeCandidateUids(candidate).forEach(uid => { if (!group.uids.includes(uid)) group.uids.push(uid); }); const subject = String(candidate?.subject || '').trim(); if (subject && !group.subjects.includes(subject)) group.subjects.push(subject); if (!group.url && urlMethod?.target) group.url = String(urlMethod.target); }); return Array.from(groups.values()); } function _askAgentToUnsubscribeAll(candidates) { const reviewed = _agentUnsubscribeGroups(candidates) .filter(item => item.uids.length || item.url); if (!reviewed.length) { showToast?.('No remaining unsubscribe candidates'); return false; } const account = state._libAccountId || ''; const lines = reviewed.map((item, index) => ( `${index + 1}. ${item.sender || '(unknown sender)'} | ${item.subjects.slice(0, 3).join(' / ') || item.subject} | UID(s): ${item.uids.join(', ') || '(none)'} | URL: ${item.url || '(none)'}` )).join('\n'); const prompt = `The user explicitly chose Agent Unsubscribe All for these already-reviewed unsubscribe candidates. They are grouped by sender: open at most one unsubscribe flow per sender and never repeat an unsubscribe attempt for the same sender. Use the private_browser tool for each exact unsubscribe URL. Do not use web_search, web_fetch, bash, or scan_email_unsubscribes. If a page is ambiguous or unsubscribe fails, skip that sender and report it. After a successful unsubscribe, use bulk_email action=delete only for that sender group's exact reviewed UID(s), so those messages are not rediscovered. Do not delete any other messages. Report successful, skipped, and failed senders clearly.\n\nFolder: ${state._libFolder || 'INBOX'}\nAccount: ${account || '(default)'}\n\nReviewed sender groups (${reviewed.length}):\n${lines}`; return _submitAgentUnsubscribePrompt(prompt); } function _unsubscribeCandidateUids(candidate) { const out = []; const seen = new Set(); const add = (uid) => { const val = String(uid || '').trim(); if (!val || seen.has(val)) return; seen.add(val); out.push(val); }; add(candidate?.uid); (candidate?.duplicate_uids || []).forEach(add); return out; } function _unsubscribeHandledStorageKey() { return `odysseus.email.unsubscribeHandled.${String(state._libAccountId || 'default')}`; } function _unsubscribeScanStorageKey() { const account = String(state._libAccountId || 'default'); const folder = String(state._libFolder || 'INBOX'); return `odysseus.email.unsubscribeScan.v2.${account}.${folder}`; } function _readUnsubscribeScanCache() { try { const parsed = JSON.parse(localStorage.getItem(_unsubscribeScanStorageKey()) || 'null'); return parsed && Array.isArray(parsed.candidates) ? parsed : null; } catch (_) { return null; } } function _writeUnsubscribeScanCache(data, scanLimit) { if (!data || !Array.isArray(data.candidates)) return; try { localStorage.setItem(_unsubscribeScanStorageKey(), JSON.stringify({ candidates: data.candidates, scanned: Number(data.scanned || 0), raw_total: Number(data.raw_total || data.candidates.length || 0), scanLimit: scanLimit == null ? 180 : Number(scanLimit), scanMode: Number(scanLimit || 0) > 0 ? 'limited' : 'whole-folder', savedAt: Date.now(), })); } catch (_) {} } function _readHandledUnsubscribeState() { try { const parsed = JSON.parse(localStorage.getItem(_unsubscribeHandledStorageKey()) || '{}'); return { uids: new Set(Array.isArray(parsed.uids) ? parsed.uids.map(String) : []), senders: new Set(Array.isArray(parsed.senders) ? parsed.senders.map(v => String(v).toLowerCase()) : []), ignoredUids: new Set(Array.isArray(parsed.ignoredUids) ? parsed.ignoredUids.map(String) : []), ignoredSenders: new Set(Array.isArray(parsed.ignoredSenders) ? parsed.ignoredSenders.map(v => String(v).toLowerCase()) : []), }; } catch (_) { return { uids: new Set(), senders: new Set(), ignoredUids: new Set(), ignoredSenders: new Set() }; } } function _rememberUnsubscribeHandled(candidates) { const current = _readHandledUnsubscribeState(); (candidates || []).forEach(candidate => { _unsubscribeCandidateUids(candidate).forEach(uid => current.uids.add(String(uid))); const sender = String(candidate?.from_address || '').trim().toLowerCase(); if (sender) current.senders.add(sender); }); try { localStorage.setItem(_unsubscribeHandledStorageKey(), JSON.stringify({ uids: Array.from(current.uids).slice(-2000), senders: Array.from(current.senders).slice(-500), ignoredUids: Array.from(current.ignoredUids).slice(-2000), ignoredSenders: Array.from(current.ignoredSenders).slice(-500), })); } catch (_) {} } function _rememberUnsubscribeIgnored(candidate) { const current = _readHandledUnsubscribeState(); _unsubscribeCandidateUids(candidate).forEach(uid => current.ignoredUids.add(String(uid))); const sender = String(candidate?.from_address || '').trim().toLowerCase(); if (sender) current.ignoredSenders.add(sender); try { localStorage.setItem(_unsubscribeHandledStorageKey(), JSON.stringify({ uids: Array.from(current.uids).slice(-2000), senders: Array.from(current.senders).slice(-500), ignoredUids: Array.from(current.ignoredUids).slice(-2000), ignoredSenders: Array.from(current.ignoredSenders).slice(-500), })); } catch (_) {} } function _filterHandledUnsubscribeCandidates(candidates) { const handled = _readHandledUnsubscribeState(); return (candidates || []).flatMap(candidate => { const sender = String(candidate?.from_address || '').trim().toLowerCase(); if (sender && (handled.senders.has(sender) || handled.ignoredSenders.has(sender))) return []; const remainingUids = _unsubscribeCandidateUids(candidate) .filter(uid => !handled.uids.has(String(uid)) && !handled.ignoredUids.has(String(uid))); if (!remainingUids.length) return []; if (remainingUids.length === _unsubscribeCandidateUids(candidate).length) return [candidate]; return [{ ...candidate, uid: remainingUids[0], duplicate_uids: remainingUids.slice(1), duplicate_count: remainingUids.length, }]; }); } function _dedupeUnsubscribeCandidatesForDisplay(candidates) { const out = []; const seen = new Map(); (candidates || []).forEach(c => { const method = c?.recommended_method || {}; const urlMethod = (c?.methods || []).find(m => m?.kind === 'url'); // One sender address should produce one review action, even when every // message carries a different tokenized unsubscribe URL. const sender = String(c?.from_address || '').trim().toLowerCase(); const key = sender || String(c?.list_id || urlMethod?.target || method.target || c?.uid || '').trim().toLowerCase(); if (!key || !seen.has(key)) { if (key) seen.set(key, c); out.push(c); return; } const existing = seen.get(key); existing.duplicate_count = Number(existing.duplicate_count || 1) + Number(c.duplicate_count || 1); const uidSet = new Set(_unsubscribeCandidateUids(existing)); _unsubscribeCandidateUids(c).forEach(uid => uidSet.add(uid)); existing.duplicate_uids = Array.from(uidSet); }); return out; } function _markUnsubscribeCardDone(modal, idx, label = 'Unsubscribed') { const card = modal?.querySelector?.(`.email-unsub-card[data-idx="${idx}"]`); if (!card) return; card.classList.add('is-unsubscribed'); if (!card.querySelector('.email-unsub-done-badge')) { const badgeHost = card.querySelector('.email-unsub-subject-line') || card.querySelector('.email-unsub-card-top'); badgeHost?.insertAdjacentHTML('beforeend', `${_UNSUB_CHECK_ICON}${_esc(label)}`); } card.querySelectorAll('.email-unsub-link-btn, .email-unsub-agent-btn, .email-unsub-send-btn, .email-unsub-done-btn, .email-unsub-ignore-btn').forEach(el => { if (el.tagName === 'A') { el.setAttribute('aria-disabled', 'true'); el.style.pointerEvents = 'none'; } else { el.disabled = true; } el.style.opacity = '0.55'; }); } function _agentToolArgs(data) { const candidates = [data?.args, data?.tool_args, data?.command]; for (const value of candidates) { if (value && typeof value === 'object' && !Array.isArray(value)) return value; if (typeof value !== 'string' || !value.trim()) continue; try { const parsed = JSON.parse(value); if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed; } catch (_) { // Some tool cards prefix the JSON with a display label. Recover the // object so email mutations still reconcile from the streamed event. const start = value.indexOf('{'); const end = value.lastIndexOf('}'); if (start >= 0 && end > start) { try { const parsed = JSON.parse(value.slice(start, end + 1)); if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed; } catch (_) {} } } } return {}; } function _agentBrowserUnsubscribeSucceeded(data) { const tool = String(data?.tool || '').toLowerCase(); if (!tool.includes('private_browser')) return false; const output = String(data?.output || ''); if (/\b(?:not|never|failed|unable|could not|cannot|can't)\b[^.!?\n]{0,50}\bunsubscrib/i.test(output)) { return false; } return /\b(?:already\s+unsubscribed|you(?:'re|\s+are)\s+(?:now\s+)?unsubscribed|successfully\s+unsubscribed|unsubscribed\s+(?:successfully|from)|unsubscription\s+(?:successful|complete)|removed\s+from\s+(?:the\s+)?(?:mailing|email)\s+list)\b/i.test(output); } function _agentBrowserCandidate(run, args) { const url = String(args?.url || '').trim(); if (url && Array.isArray(run?.candidates)) { const matched = run.candidates.find(candidate => ( (candidate?.methods || []).some(method => method?.kind === 'url' && String(method.target || '') === url) )); if (matched) return matched; } return run?.activeCandidate || null; } function _trackAgentBrowserUnsubscribe(data) { const modal = document.getElementById('email-unsubscribe-review-modal'); const run = modal?._agentUnsubscribeRun; if (!run) return; const args = _agentToolArgs(data); const action = String(args.action || data?.action || '').toLowerCase(); const candidate = _agentBrowserCandidate(run, args); if (action === 'open' && candidate) run.activeCandidate = candidate; if (!candidate || !_agentBrowserUnsubscribeSucceeded(data)) return; run.cleanupInFlight ||= new Set(); if (run.cleanupInFlight.has(candidate) || run.completed.has(candidate)) return; run.cleanupInFlight.add(candidate); _deleteAfterUnsubscribe([candidate]).then(result => { if (Number(result?.failed || 0) > 0 || Number(result?.changed || 0) <= 0) { run.failed ||= new Set(); run.failed.add(candidate); run.restoreBusy?.setLabel?.(`Unsubscribed, delete failed (${run.completed.size} / ${run.total})`); modal.querySelector('.email-unsub-status')?.replaceChildren(document.createTextNode( 'Unsubscribe succeeded, but the matching email could not be moved to Trash. Review it below.', )); return; } const idx = run.candidates.indexOf(candidate); run.completed.add(candidate); _markUnsubscribeCardDone(modal, idx, 'Unsubscribed'); run.restoreBusy?.setLabel?.(`Unsubscribed ${run.completed.size} / ${run.total}`); }).catch(error => { run.failed ||= new Set(); run.failed.add(candidate); run.restoreBusy?.setLabel?.(`Unsubscribed, delete failed (${run.completed.size} / ${run.total})`); modal.querySelector('.email-unsub-status')?.replaceChildren(document.createTextNode( `Unsubscribe succeeded, but delete failed: ${error?.message || 'mail server error'}`, )); }); } function _agentDeletedEmailUids(data) { const tool = String(data?.tool || '').toLowerCase(); const args = _agentToolArgs(data); const action = String(args.action || data.action || '').toLowerCase(); const output = String(data?.output || '').toLowerCase(); const failed = /\b(?:failed|error|could not|unable|not found)\b/.test(output); const isBulkDelete = tool.includes('bulk_email') && ['delete', 'trash', 'move_to_trash'].includes(action); const isSingleDelete = tool.endsWith('delete_email') && !tool.includes('bulk_email'); const isSuccessfulMailtoUnsubscribe = tool.includes('unsubscribe_email') && !failed && /(?:source email moved to trash|deleted|unsubscribe email sent)/.test(output); if (!isBulkDelete && !isSingleDelete && !isSuccessfulMailtoUnsubscribe) return []; if ((isBulkDelete || isSingleDelete) && failed) return []; const values = [ ...(Array.isArray(args.uids) ? args.uids : []), ...(Array.isArray(data.deleted_uids) ? data.deleted_uids : []), ...(Array.isArray(data.cleaned_uids) ? data.cleaned_uids : []), ]; if (args.uid != null) values.push(args.uid); if (data.uid != null) values.push(data.uid); return Array.from(new Set(values.map(uid => String(uid || '').trim()).filter(Boolean))); } function _handleAgentEmailToolOutput(event) { const data = event?.detail || {}; _trackAgentBrowserUnsubscribe(data); const ok = data.exit_code == null ? data.success !== false : data.exit_code === 0; if (!ok) return; const deletedUids = new Set(_agentDeletedEmailUids(data)); if (!deletedUids.size) return; state._libEmails = (state._libEmails || []).filter(email => !deletedUids.has(String(email?.uid || ''))); try { _libCacheWriteBack(); } catch (_) {} try { _renderGrid(); } catch (_) {} const modal = document.getElementById('email-unsubscribe-review-modal'); const completed = []; (modal?._unsubscribeCandidates || []).forEach((candidate, idx) => { const remaining = _unsubscribeCandidateUids(candidate) .filter(uid => !deletedUids.has(String(uid))); if (remaining.length === _unsubscribeCandidateUids(candidate).length) return; if (!remaining.length) { completed.push(candidate); _markUnsubscribeCardDone(modal, idx, 'Agent done'); return; } candidate.uid = remaining[0]; candidate.duplicate_uids = remaining.slice(1); candidate.duplicate_count = remaining.length; }); const run = modal?._agentUnsubscribeRun; if (run && completed.length) { completed.forEach(candidate => run.completed.add(candidate)); run.restoreBusy?.setLabel?.(`Unsubscribed ${run.completed.size} / ${run.total}`); } if (completed.length) _rememberUnsubscribeHandled(completed); const visible = (modal?._unsubscribeCandidates || []).filter(candidate => ( _unsubscribeCandidateUids(candidate).some(uid => !deletedUids.has(String(uid))) )); if (modal && !visible.length) { if (run) { run.restoreBusy?.setLabel?.(`Unsubscribed ${run.total} / ${run.total}`); run.restoreBusy = null; modal._agentUnsubscribeRun = null; } modal.querySelector('.email-unsub-list')?.style.setProperty('display', 'none'); modal.querySelector('.email-unsub-followup')?.remove(); modal.querySelector('.email-unsub-status')?.replaceChildren(document.createTextNode('Agent unsubscribe cleanup completed.')); } } window.addEventListener('odysseus:agent-tool-output', _handleAgentEmailToolOutput); function _handleAgentUnsubscribeTerminal(event) { const modal = document.getElementById('email-unsubscribe-review-modal'); const run = modal?._agentUnsubscribeRun; if (!run) return; const detail = event?.detail?.data || {}; (Array.isArray(detail.tool_events) ? detail.tool_events : []).forEach(_trackAgentBrowserUnsubscribe); const completed = run.completed.size; const remaining = Math.max(0, run.total - completed); const failure = detail.failure || (detail.failed ? { message: 'The agent run failed before all unsubscribe flows completed.' } : null); run.restoreBusy?.(); modal._agentUnsubscribeRun = null; modal.querySelector('.email-unsub-status')?.replaceChildren(document.createTextNode( failure ? `Agent unsubscribe failed: ${completed} / ${run.total} completed. ${remaining} still need review. ${failure.message || ''}`.trim() : remaining ? `Agent unsubscribe finished: ${completed} / ${run.total} completed. ${remaining} still need review.` : `Agent unsubscribe finished: ${completed} / ${run.total} completed.`, )); // A tool result can arrive just after the final browser step. Re-fetch once // the run is over so an old mailbox page or server-side list cache cannot // leave deleted messages visible until the next manual refresh. if (!failure && typeof refreshEmailLibrary === 'function') { setTimeout(() => refreshEmailLibrary().catch(() => {}), 0); } } window.addEventListener('odysseus:agent-terminal', _handleAgentUnsubscribeTerminal); async function _runUnsubscribeCleanup(modal, candidates, action, btn) { const uids = []; const seen = new Set(); (candidates || []).forEach(c => { _unsubscribeCandidateUids(c).forEach(uid => { if (seen.has(uid)) return; seen.add(uid); uids.push(uid); }); }); if (!uids.length) { showToast?.('No emails to update'); return; } const busyLabel = action === 'junk' ? 'Marking spam' : action === 'junk_delete' ? 'Spam + delete' : 'Deleting'; const restoreBusy = _setUnsubButtonBusy(btn, busyLabel); try { const r = await fetch(emailApiUrl('/api/email/unsubscribe/cleanup', { account_id: state._libAccountId || undefined, }), { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action, uids, folder: state._libFolder || 'INBOX', account_id: state._libAccountId || '', }), }); const d = await r.json().catch(() => ({})); if (!d.success) throw new Error(d.error || 'Cleanup failed'); const removed = new Set(uids.map(uid => String(uid))); state._libEmails = state._libEmails.filter(e => !removed.has(String(e.uid))); try { _libCacheWriteBack(); } catch (_) {} try { _renderGrid(); } catch (_) {} _rememberUnsubscribeHandled(candidates); modal.querySelector('.email-unsub-followup')?.remove(); showToast?.(action === 'junk' ? `Marked ${d.changed || 0} email${Number(d.changed || 0) === 1 ? '' : 's'} as spam` : action === 'junk_delete' ? `Marked ${d.changed || 0} email${Number(d.changed || 0) === 1 ? '' : 's'} as spam and deleted ${d.changed || 0}` : `Deleted ${d.changed || 0} email${Number(d.changed || 0) === 1 ? '' : 's'}`); } catch (err) { console.error(err); showToast?.(err?.message || 'Cleanup failed'); } finally { restoreBusy?.(); } } async function _deleteAfterUnsubscribe(candidates) { const groups = new Map(); (candidates || []).forEach(c => { const sender = String(c?.from_address || '').trim(); const key = sender.toLowerCase() || `uids:${_unsubscribeCandidateUids(c).join(',')}`; if (!groups.has(key)) groups.set(key, { sender, uids: [] }); const group = groups.get(key); const seen = new Set(group.uids); _unsubscribeCandidateUids(c).forEach(uid => { if (!seen.has(uid)) { seen.add(uid); group.uids.push(uid); } }); }); if (!groups.size) return { success: true, changed: 0, failed: 0, cleaned_uids: [] }; const result = { success: true, changed: 0, failed: 0, cleaned_uids: [] }; for (const group of groups.values()) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 30000); let r; try { r = await fetch(emailApiUrl('/api/email/unsubscribe/cleanup', { account_id: state._libAccountId || undefined, }), { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, signal: controller.signal, body: JSON.stringify({ action: 'delete', scope: group.sender ? 'sender_unsubscribe' : '', sender: group.sender, uids: group.sender ? [] : group.uids, folder: state._libFolder || 'INBOX', account_id: state._libAccountId || '', }), }); } catch (err) { if (err?.name === 'AbortError') throw new Error('Deleting timed out; the mail server did not respond'); throw err; } finally { clearTimeout(timeout); } const d = await r.json().catch(() => ({})); if (!d.success) throw new Error(d.error || 'Could not move unsubscribed emails to Trash'); result.changed += Number(d.changed || 0); result.failed += Number(d.failed || 0); result.cleaned_uids.push(...(d.cleaned_uids || [])); } const removed = new Set(result.cleaned_uids.map(uid => String(uid))); state._libEmails = state._libEmails.filter(e => !removed.has(String(e.uid))); try { _libCacheWriteBack(); } catch (_) {} try { _renderGrid(); } catch (_) {} _rememberUnsubscribeHandled(candidates); return result; } function _showUnsubscribeCleanupPrompt(modal, candidates, options = {}) { if (!modal || !Array.isArray(candidates) || !candidates.length) return; modal.querySelector('.email-unsub-followup')?.remove(); const count = candidates.reduce((sum, c) => sum + _unsubscribeCandidateUids(c).length, 0); const heading = options.heading || `Done. Mark all ${count} of these email${count === 1 ? '' : 's'} as spam?`; const rows = candidates.map(c => { const uids = _unsubscribeCandidateUids(c); return `