/** * GoingsOn - Emails Module * Email list, compose, threading, actions (archive/delete/mark). * Account management and OAuth live in email-accounts.js. */ // ============ Emails Module ============ (function() { 'use strict'; const esc = GoingsOn.utils.escapeHtml; const escAttr = GoingsOn.utils.escapeAttrValue; const escArg = GoingsOn.utils.escapeHandlerArg; const escAttrVal = GoingsOn.utils.escapeAttrValue; // ============ Email Selection & Pagination ============ // Use new utility managers const emailSelection = new GoingsOn.SelectionManager('email', '#email-list', 'email-bulk-actions'); const emailPagination = new GoingsOn.PaginationManager('email', GoingsOn.state.itemsPerPage); // Legacy alias for backward compatibility const selectedEmailIds = emailSelection.selectedIds; // Virtual scroller instance let emailScroller = null; // Email threads stored in centralized state for virtual scrolling GoingsOn.state.set('emailThreads', []); // CHRONIC-E structural fix (ultra-fuzz Run #28 S3/S4). Reading an email — the // single most frequent action in the app — used to refetch the whole 500-thread // window. Wire one subscriber so a surgical state.set('emailThreads', …) // re-renders the list in place. `suppressEmailRender` gates it during the full // rebuild in load()/searchEmails so set() there doesn't refresh a scroller that // is about to be (re)created on the next line. let suppressEmailRender = false; GoingsOn.state.subscribe('emailThreads', () => { if (suppressEmailRender) return; if (emailScroller) emailScroller.refresh(); }); // ============ Surgical single-thread list mutations (CHRONIC-E) ============ // Each mutates state.emailThreads in place and lets the subscriber refresh the // scroller — replacing the old `reload: load` that refetched 500 threads on // every single-email action (ultra-fuzz Run #28 S4). Threads are matched by // their representative (most-recent) email id, which is the id the list rows // and the reader action bar operate on. /** Drop the thread whose representative email is `emailId` from the list. */ function _removeThread(emailId) { const threads = GoingsOn.state.emailThreads || []; GoingsOn.state.set('emailThreads', threads.filter(t => t.mostRecentEmail.id !== emailId)); } /** Toggle a thread's read state. Mirrors the bulk markRead pattern. */ function _setThreadRead(emailId, read) { const threads = GoingsOn.state.emailThreads || []; GoingsOn.state.set('emailThreads', threads.map(t => t.mostRecentEmail.id === emailId ? { ...t, mostRecentEmail: { ...t.mostRecentEmail, isRead: read }, hasUnread: !read } : t )); } /** * Mark the opened message read and recompute the thread's unread flag from the * thread we just loaded. hasUnread is cleared only when no *other* message in * the thread is still unread, so a multi-message thread with remaining unread * keeps its dot rather than wrongly showing read (GO todo hazard note). */ function _markThreadReadForEmail(emailId, threadEmails) { const threads = GoingsOn.state.emailThreads || []; const stillUnread = (threadEmails || []).some(e => e.id !== emailId && !e.isRead); let changed = false; const next = threads.map(t => { if (t.mostRecentEmail.id !== emailId) return t; changed = true; return { ...t, mostRecentEmail: { ...t.mostRecentEmail, isRead: true }, hasUnread: stillUnread }; }); if (changed) GoingsOn.state.set('emailThreads', next); } // Incremental pagination. The thread list streams in pages via the scroller's // onNeedMore hook instead of capping at a fixed 500 rows, so a large mailbox's // tail is reachable (ultra-fuzz Run #28 S4). baseFilters holds the current // folder/label so each page request stays consistent. const EMAIL_PAGE_SIZE = 200; const emailPaging = { loadedCount: 0, total: 0, baseFilters: null }; /** * Fetch and append the next page of threads. Wired to the scroller's * onNeedMore hook. No-ops once every thread is loaded; on error it surfaces a * toast and stops paging rather than spinning. */ async function loadMoreEmails() { if (emailPaging.loadedCount >= emailPaging.total || !emailPaging.baseFilters) return; let response; try { response = await GoingsOn.api.emails.listThreaded({ ...emailPaging.baseFilters, offset: emailPaging.loadedCount, limit: EMAIL_PAGE_SIZE, }); } catch (err) { GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load more emails'), 'error'); return; } const threads = (GoingsOn.state.emailThreads || []).concat(response.threads); GoingsOn.state.set('emails', threads.map(t => t.mostRecentEmail)); GoingsOn.state.set('emailThreads', threads); emailPaging.loadedCount = threads.length; emailPaging.total = response.total; emailSelection.setItems(threads.map(t => ({ id: t.mostRecentEmail.id }))); _updateEmailCount(emailPaging.total, emailPaging.loadedCount); // Re-arms onNeedMore so the next page can load on further scroll. if (emailScroller) emailScroller.refresh(); } // ============ Core Functions ============ /** * Fetch threaded emails and render via virtual scroller. */ async function load() { if (GoingsOn.cache.isFresh('emails')) return; // Phase 7 Tier 4 — pull active folder/label/search from URL on first // load (e.g. after reload or deep-link). Subsequent filter changes // are already URL-mirrored by the respective handlers. restoreFiltersFromUrl(); const initialSearch = GoingsOn.queryState?.read('q'); if (initialSearch) { searchEmails(initialSearch); return; } const container = document.getElementById('email-list'); // Suppress the state subscriber for the whole rebuild (see subscribe() above). suppressEmailRender = true; // Reset paging for the current folder/label and fetch the first page; the // list streams in later pages via the scroller's onNeedMore hook. const baseFilters = { includeArchived: false, folder: activeFolder || null, label: activeLabel || null, }; emailPaging.baseFilters = baseFilters; emailPaging.loadedCount = 0; emailPaging.total = 0; try { // Fetch the first page of pre-grouped threads from the backend. const response = await GoingsOn.api.emails.listThreaded({ ...baseFilters, offset: 0, limit: EMAIL_PAGE_SIZE, }); // Refresh filter dropdowns loadFilters(); // Update cache with most recent emails GoingsOn.state.set('emails', response.threads.map(t => t.mostRecentEmail)); GoingsOn.state.set('emailThreads', response.threads); emailPaging.loadedCount = response.threads.length; emailPaging.total = response.total; // Phase 7 Tier 2 #9 — surface the count; reads "X of N" while later // pages stream in on scroll. _updateEmailCount(response.total, response.threads.length); if (response.total === 0) { const hasAccounts = GoingsOn.getEmailAccountsCache().length > 0; container.innerHTML = hasAccounts ? GoingsOn.ui.renderEmptyState('No emails yet.', 'Compose', 'emails.openCompose', 'emails') : GoingsOn.ui.renderEmptyState('Set up an email account to get started.', 'Add Account', 'emails.openAccountsModal', 'inbox'); // Hide pagination const paginationEl = document.getElementById('email-pagination'); if (paginationEl) paginationEl.classList.add('hidden'); // Destroy scroller if (emailScroller) { emailScroller.destroy(); emailScroller = null; } return; } // Update selection manager with current items for data-based range selection emailSelection.setItems(response.threads.map(t => ({ id: t.mostRecentEmail.id }))); // Hide pagination - virtual scrolling replaces it const paginationEl = document.getElementById('email-pagination'); if (paginationEl) paginationEl.classList.add('hidden'); // Initialize or refresh virtual scroller if (!emailScroller) { emailScroller = new GoingsOn.VirtualScroller({ container: container, renderItem: (t, i) => GoingsOn.emailsRender.renderEmailItem(t, i), getItems: () => GoingsOn.state.emailThreads, rowHeight: { estimated: 90, measure: true }, overscan: 5, onNeedMore: loadMoreEmails, }); } else { emailScroller.refresh(); } GoingsOn.cache.markLoaded('emails'); } catch (err) { container.innerHTML = `
Failed to load emails.
`; GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load emails'), 'error', { action: { label: 'Retry', fn: load }, duration: 8000, }); } finally { suppressEmailRender = false; } } /** * Render a single email item (for virtual scrolling). * @param {Object} thread - Thread object with mostRecentEmail * @param {number} index - Item index * @returns {string} HTML string */ async function markAllRead() { await GoingsOn.ui.apiCall(GoingsOn.api.emails.markAllRead(), { successMessage: 'All emails marked as read!', errorMessage: 'Failed to mark emails as read', closeModal: false, onSuccess: () => { GoingsOn.cache.invalidate('emails'); // Surgical: clear unread across the streamed threads in place. const threads = GoingsOn.state.emailThreads || []; GoingsOn.state.set('emailThreads', threads.map(t => ({ ...t, mostRecentEmail: { ...t.mostRecentEmail, isRead: true }, hasUnread: false, }))); }, }); } /** * Open an email in reader mode, loading its full thread if available. * Marks the email as read and shows sender contact info. * @param {string} id - Email ID to open */ async function open(id) { try { const email = await GoingsOn.api.emails.get(id); if (!email) return; // Mark as read await GoingsOn.api.emails.markRead(id); // Check if this email is part of a thread let threadEmails = [email]; if (email.threadId) { try { const thread = await GoingsOn.api.emails.listByThread(email.threadId); if (thread && thread.length > 1) { // Backend returns threads sorted by received_at ASC threadEmails = thread; } } catch (e) { console.error('Failed to load thread:', e); } } const isThread = threadEmails.length > 1; // Build action buttons for the most recent email const latestEmail = threadEmails[threadEmails.length - 1]; const archiveBtn = latestEmail.isArchived ? `` : ``; // Use pre-computed field from backend const isSnoozed = latestEmail.isSnoozed; const snoozeBtn = isSnoozed ? `` : ``; // Look up contact from sender email const parsed = GoingsOn.utils.parseEmailAddress(email.from); let senderContact = null; if (parsed.email) { try { senderContact = await GoingsOn.api.contacts.findByEmail(parsed.email); } catch (e) { console.error('Failed to look up contact:', e); } } // Build sender contact card let contactCardHtml = ''; if (senderContact) { const initials = (senderContact.displayName || senderContact.display_name || '?') .split(/\s+/).map(w => w[0]).join('').substring(0, 2).toUpperCase(); const company = senderContact.company ? esc(senderContact.company) : ''; contactCardHtml = `
${initials}
${company ? `` : ''}
`; } else if (parsed.email) { contactCardHtml = `
?
`; } // Build attachment panel from all emails in thread const allAttachments = threadEmails.flatMap(e => (e.attachments || []).map(a => ({ ...a, emailFrom: e.from })) ); let attachmentHtml = ''; if (allAttachments.length > 0) { const attachmentItems = allAttachments.map(a => { const icon = GoingsOn.attachments.getIcon(a.mimeType); return `
${icon} ${esc(a.filename)} ${esc(a.sizeFormatted)}
`; }).join(''); attachmentHtml = `
Attachments (${allAttachments.length})
${attachmentItems}
`; } // Render thread in forum style (oldest first) const threadContent = threadEmails.map((e, index) => { const isLatest = index === threadEmails.length - 1; const dateStr = new Date(e.receivedAt).toLocaleString(); const directionIcon = e.isOutgoing ? '↗' : '↙'; // arrows for direction const formattedBody = GoingsOn.utils.formatEmailBody(e.body); // Bodies over 100KB are truncated at sync; offer to load the rest. const truncatedNotice = e.bodyTruncated ? `
Message truncated.
` : ''; return `
${directionIcon} ${esc(e.from)} ${dateStr}
${formattedBody}
${truncatedNotice}
`; }).join(''); const subjectLine = isThread ? `${esc(email.subject)} (${threadEmails.length} messages)` : esc(email.subject); const content = `
${contactCardHtml}
${attachmentHtml}
${threadContent}
${archiveBtn} ${snoozeBtn}
`; GoingsOn.ui.openModal(isThread ? 'Thread' : 'Email', content, { large: true }); // Surgically reflect the read state instead of refetching the whole // window (ultra-fuzz Run #28 S4) — this is the single most frequent // action in the app. threadEmails is the thread we just loaded. GoingsOn.cache.invalidate('emails'); _markThreadReadForEmail(id, threadEmails); } catch (err) { GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load email'), 'error'); } } /** * Delete an email with confirmation dialog. * @param {string} id - Email ID to delete */ async function deleteEmail(id) { if (!await GoingsOn.ui.confirmDelete('email')) return; await GoingsOn.ui.apiCall(GoingsOn.api.emails.delete(id), { successMessage: 'Email deleted!', errorMessage: 'Failed to delete email', onSuccess: () => { GoingsOn.cache.invalidate('emails'); _removeThread(id); // surgical: the deleted email leaves the list }, }); } /** * Archive an email (also moves on IMAP server if available). * @param {string} id - Email ID to archive */ async function archive(id) { await GoingsOn.ui.apiCall(GoingsOn.api.emails.archive(id), { successMessage: 'Email archived!', errorMessage: 'Failed to archive email', onSuccess: () => { GoingsOn.cache.invalidate('emails'); _removeThread(id); // surgical: archived email leaves the inbox view }, }); } /** * Unarchive an email. * @param {string} id - Email ID to unarchive */ async function unarchive(id) { await GoingsOn.ui.apiCall(GoingsOn.api.emails.unarchive(id), { successMessage: 'Email unarchived!', errorMessage: 'Failed to unarchive email', onSuccess: () => { GoingsOn.cache.invalidate('emails'); // Surgical: drop it from the archived-folder view. In the inbox view // it isn't present, so this is a no-op there. _removeThread(id); }, }); } /** * Mark an email as read. * @param {string} id - Email ID */ async function markRead(id) { await GoingsOn.ui.apiCall(GoingsOn.api.emails.markRead(id), { errorMessage: 'Failed to mark email as read', closeModal: false, onSuccess: () => { GoingsOn.cache.invalidate('emails'); _setThreadRead(id, true); }, }); } /** * Mark an email as unread. * @param {string} id - Email ID */ async function markUnread(id) { await GoingsOn.ui.apiCall(GoingsOn.api.emails.markUnread(id), { errorMessage: 'Failed to mark email as unread', closeModal: false, onSuccess: () => { GoingsOn.cache.invalidate('emails'); _setThreadRead(id, false); }, }); } /** * Create a new contact from an email's sender address. * Parses the From field, creates the contact, and adds the email address. * @param {string} emailId - Email ID to extract sender from */ async function createContactFromSender(emailId) { try { const email = await GoingsOn.api.emails.get(emailId); if (!email) { GoingsOn.ui.showToast('Email not found', 'error'); return; } const parsed = GoingsOn.utils.parseEmailAddress(email.from); if (!parsed.email) { GoingsOn.ui.showToast('Could not parse email address', 'error'); return; } // Split parsed name into first/last for display name const displayName = parsed.name || parsed.email.split('@')[0]; // Create the contact const contact = await GoingsOn.api.contacts.create({ displayName: displayName, }); // Add the email address to the new contact await GoingsOn.api.contacts.addEmail(contact.id, { address: parsed.email, label: 'Work', isPrimary: true, }); // Refresh contacts cache GoingsOn.cache.invalidate('contacts'); const contacts = await GoingsOn.api.contacts.list(); GoingsOn.state.set('contacts', contacts); GoingsOn.ui.showToast('Contact saved!', 'success'); // Re-open the email reader to show the updated contact card open(emailId); } catch (err) { GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to create contact'), 'error'); } } /** * Create a task from an email's subject and sender info. * Auto-links the sender's contact if one exists. * @param {string} emailId - Source email ID */ async function createTaskFromEmail(emailId) { try { const email = await GoingsOn.api.emails.get(emailId); if (!email) { GoingsOn.ui.showToast('Email not found', 'error'); return; } // Auto-link contact from sender email let contactId = null; const parsed = GoingsOn.utils.parseEmailAddress(email.from); if (parsed.email) { try { const contact = await GoingsOn.api.contacts.findByEmail(parsed.email); if (contact) contactId = contact.id; } catch (_) { /* optional — contact may not exist */ } } const taskData = { description: email.subject, projectId: email.projectId || null, priority: 'Medium', due: null, tags: [], recurrence: 'None', sourceEmailId: emailId, contactId: contactId, }; await GoingsOn.api.tasks.create(taskData); GoingsOn.ui.showToast('Task created from email!', 'success'); GoingsOn.ui.closeModal(); GoingsOn.cache.invalidate('tasks'); GoingsOn.tasks.load(); } catch (err) { GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to create task'), 'error'); } } /** * Create a calendar event from an email's subject and body. * Defaults to a 1-hour event starting at the next hour. * @param {string} emailId - Source email ID */ async function createEventFromEmail(emailId) { try { const email = await GoingsOn.api.emails.get(emailId); if (!email) { GoingsOn.ui.showToast('Email not found', 'error'); return; } // Auto-link contact from sender email let contactId = null; const parsed = GoingsOn.utils.parseEmailAddress(email.from); if (parsed.email) { try { const contact = await GoingsOn.api.contacts.findByEmail(parsed.email); if (contact) contactId = contact.id; } catch (_) { /* optional — contact may not exist */ } } // Default to 1 hour from now, rounded to next hour const now = new Date(); now.setMinutes(0, 0, 0); now.setHours(now.getHours() + 1); const startTime = now.toISOString().slice(0, 16); // Format for datetime-local const endTime = new Date(now.getTime() + 60 * 60 * 1000); // 1 hour later const endTimeStr = endTime.toISOString().slice(0, 16); const eventData = { title: email.subject, projectId: email.projectId || null, startTime: startTime, endTime: endTimeStr, location: '', description: `From: ${email.from}\n\n${email.body.substring(0, 500)}${email.body.length > 500 ? '...' : ''}`, isAllDay: false, contactId: contactId, }; await GoingsOn.api.events.create(eventData); GoingsOn.ui.showToast('Event created from email!', 'success'); GoingsOn.ui.closeModal(); GoingsOn.cache.invalidate('events'); GoingsOn.events.load(); } catch (err) { GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to create event'), 'error'); } } /** * Open compose window for a reply. * @param {string} emailId - Email to reply to * @param {boolean} replyAll - If true, include all recipients */ /** * Open an email attachment blob with the system default app. * @param {string} blobHash - SHA-256 hash of the blob * @param {string} filename - Original filename */ async function openBlob(blobHash, filename) { try { await GoingsOn.api.attachments.openEmailBlob(blobHash, filename); } catch (err) { GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to open attachment'), 'error'); } } /** * Save an email attachment blob to a user-chosen location. * @param {string} blobHash - SHA-256 hash of the blob * @param {string} filename - Default filename for save dialog */ async function saveBlob(blobHash, filename) { try { const { save } = window.__TAURI__.dialog; const destination = await save({ defaultPath: filename, title: 'Save attachment as', }); if (!destination) return; await GoingsOn.api.attachments.saveEmailBlob(blobHash, destination); GoingsOn.ui.showToast('File saved!', 'success'); } catch (err) { if (err && err.toString().includes('cancelled')) return; GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to save attachment'), 'error'); } } /** * Extract bare email address from "Name " format. */ /** * Render email HTML to a temp file and open in the system browser. * @param {string} emailId - Email ID to open */ async function openInBrowser(emailId) { try { await GoingsOn.api.window.openEmailInBrowser(emailId); } catch (err) { GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to open email in browser'), 'error'); } } // Stage 4: the modal's attachment picker / render / remove are owned by // composeForm.bindBehaviors. The local pickModalAttachment / // renderModalAttachments / removeModalAttachment helpers and the // modalAttachedFiles array are gone; access via modalComposeCtrl instead. // ============ Folder & Label Filters ============ let activeFolder = ''; let activeLabel = ''; async function loadFilters() { try { const [folders, labels] = await Promise.all([ GoingsOn.api.emails.listFolders(), GoingsOn.api.emails.listLabels(), ]); const folderSelect = document.getElementById('email-folder-filter'); if (folderSelect) { const current = folderSelect.value; folderSelect.innerHTML = '' + folders.map(f => ``).join(''); } const labelSelect = document.getElementById('email-label-filter'); if (labelSelect) { const current = labelSelect.value; labelSelect.innerHTML = '' + labels.map(l => ``).join(''); } } catch (_) { /* filters are optional */ } } function filterByFolder(folder) { activeFolder = folder; GoingsOn.queryState?.write('folder', folder); clearSelectionIfAny(); GoingsOn.cache.invalidate('emails'); load(); } function filterByLabel(label) { activeLabel = label; GoingsOn.queryState?.write('label', label); clearSelectionIfAny(); GoingsOn.cache.invalidate('emails'); load(); } /** * Phase 7 Tier 4 — restore folder / label / search from URL on init. * Called once at first load; subsequent filter changes write back. */ function restoreFiltersFromUrl() { if (!GoingsOn.queryState) return; const q = GoingsOn.queryState.readMany(['folder', 'label', 'q']); if (q.folder) { activeFolder = q.folder; const sel = document.getElementById('email-folder-filter'); if (sel) sel.value = q.folder; } if (q.label) { activeLabel = q.label; const sel = document.getElementById('email-label-filter'); if (sel) sel.value = q.label; } if (q.q) { const input = document.getElementById('email-search'); if (input) input.value = q.q; } } // Charter rule (Phase 2 #1): selection clears on filter change so bulk // actions can't target rows the user can no longer see. function clearSelectionIfAny() { if (selectedEmailIds.size > 0) { emailSelection.clear(); GoingsOn.bulk?.updateBar?.(); } } /** * Open a modal to edit labels on an email. * @param {string} emailId - Email ID * @param {string[]} currentLabels - Current labels */ async function editLabels(emailId, currentLabels) { const existing = await GoingsOn.api.emails.listLabels(); const content = `
${existing.length > 0 ? `
Existing: ${existing.map(l => esc(l)).join(', ')}
` : ''}
`; GoingsOn.ui.openModal('Edit Labels', content); } async function saveLabels(emailId) { const input = document.getElementById('label-input'); const labels = input.value.split(',').map(s => s.trim()).filter(Boolean); try { await GoingsOn.api.emails.setLabels(emailId, labels); GoingsOn.ui.showToast('Labels updated!', 'success'); GoingsOn.ui.closeModal(); GoingsOn.cache.invalidate('emails'); // Surgical: patch the thread's labels in place (ultra-fuzz Run #28 S4). const threads = GoingsOn.state.emailThreads || []; GoingsOn.state.set('emailThreads', threads.map(t => t.mostRecentEmail.id === emailId ? { ...t, mostRecentEmail: { ...t.mostRecentEmail, labels } } : t )); loadFilters(); } catch (err) { GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to update labels'), 'error'); } } /** * Move an email to a different folder. * @param {string} emailId - Email ID */ async function moveToFolder(emailId) { try { const folders = await GoingsOn.api.emails.listFolders(); // Also try to get IMAP folders from account const content = `
${folders.map(f => `
`; GoingsOn.ui.openModal('Move to Folder', content); } catch (err) { GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load folders'), 'error'); } } async function doMoveToFolder(emailId) { const input = document.getElementById('move-folder-input'); const folder = input.value.trim(); if (!folder) return; try { await GoingsOn.api.emails.moveToFolder(emailId, folder); GoingsOn.ui.showToast(`Moved to ${folder}`, 'success'); GoingsOn.ui.closeModal(); GoingsOn.cache.invalidate('emails'); // Surgical: the moved email leaves the current folder/inbox view // (ultra-fuzz Run #28 S4). _removeThread(emailId); loadFilters(); } catch (err) { GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to move email'), 'error'); } } // ============ Email Search ============ let searchDebounceTimer = null; /** * Search emails using FTS5 backend. * @param {string} query - Search query text */ function searchEmails(query) { clearTimeout(searchDebounceTimer); const trimmed = (query || '').trim(); clearSelectionIfAny(); GoingsOn.queryState?.write('q', trimmed); if (!trimmed) { // Clear search — reload normal email list. Drop the search-built // scroller (which has no onNeedMore) so load() recreates it with the // paging hook re-armed (ultra-fuzz Run #28 S4). if (emailScroller) { emailScroller.destroy(); emailScroller = null; } GoingsOn.cache.invalidate('emails'); load(); return; } searchDebounceTimer = setTimeout(async () => { try { const response = await GoingsOn.api.search.query({ query: trimmed, type: 'email', limit: 100, }); const container = document.getElementById('email-list'); if (response.results.length === 0) { if (emailScroller) { emailScroller.destroy(); emailScroller = null; } container.innerHTML = `
No emails matching "${esc(trimmed)}"
`; return; } // Fetch full email data for each result const emailIds = new Set(response.results.map(r => r.id)); const allThreads = GoingsOn.state.emailThreads || []; const matchingThreads = allThreads.filter(t => emailIds.has(t.mostRecentEmail.id)); // If we have cached threads, filter them; otherwise show search results directly if (matchingThreads.length > 0) { GoingsOn.state.set('emailThreads', matchingThreads); if (emailScroller) { emailScroller.refresh(); } else { emailScroller = new GoingsOn.VirtualScroller({ container: container, renderItem: (t, i) => GoingsOn.emailsRender.renderEmailItem(t, i), getItems: () => GoingsOn.state.emailThreads, rowHeight: { estimated: 90, measure: true }, overscan: 5, }); } } else { // Render search results as simple list if (emailScroller) { emailScroller.destroy(); emailScroller = null; } container.innerHTML = response.results.map(r => `
`).join(''); } } catch (err) { console.error('Email search failed:', err); } }, 250); } // ============ Pagination ============ // Account management, OAuth, and sync live in email-accounts.js function goToPage(direction) { emailPagination.goToPage(direction); load(); } // ============ Email Selection ============ function toggleSelection(id, checkbox, event) { emailSelection.toggle(id, checkbox, event); } function selectAll() { emailSelection.selectAll(); } function getSelected() { return emailSelection.getSelected(); } function clearSelected() { emailSelection.clear(); } /** * Update the "N emails" count chip. Note: total is at thread granularity, * shown is also at thread granularity, since the filter bar describes the * visible list which renders one row per thread. */ function _updateEmailCount(total, shown) { const el = document.getElementById('email-count'); if (!el) return; if (typeof total !== 'number' || total < 0) { el.textContent = ''; el.classList.remove('filter-count--capped'); return; } const noun = total === 1 ? 'thread' : 'threads'; if (shown < total) { el.textContent = `${shown} of ${total} ${noun} — narrow with filters`; el.classList.add('filter-count--capped'); } else { el.textContent = `${total} ${noun}`; el.classList.remove('filter-count--capped'); } } // ============ Populate GoingsOn.emails Namespace ============ /** * Lazily fetch the full body of an email truncated at sync (JMAP >100KB) * and swap it into the open reader, removing the truncation notice. */ async function loadFullBody(id) { const noticeEl = document.getElementById(`email-trunc-${id}`); if (noticeEl) noticeEl.textContent = 'Loading full message'; try { const body = await GoingsOn.api.emails.fetchFullBody(id); const bodyEl = document.getElementById(`email-body-${id}`); if (bodyEl) bodyEl.innerHTML = GoingsOn.utils.formatEmailBody(body); if (noticeEl) noticeEl.remove(); } catch (err) { if (noticeEl) noticeEl.textContent = ''; GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load full message'), 'error'); } } GoingsOn.emails = { load, markAllRead, open, loadFullBody, delete: deleteEmail, archive, unarchive, markRead, markUnread, createTaskFromEmail, createEventFromEmail, createContactFromSender, openInBrowser, openBlob, saveBlob, search: searchEmails, filterByFolder, filterByLabel, editLabels, _saveLabels: saveLabels, moveToFolder, _doMoveToFolder: doMoveToFolder, // Compose/reply/forward/drafts/send-delay live in emails-compose.js; // delegate lazily so load order between the two files doesn't matter. openCompose: (...a) => GoingsOn.emailsCompose.openCompose(...a), openComposeModal: (...a) => GoingsOn.emailsCompose.openComposeModal(...a), reply: (id) => GoingsOn.emailsCompose.openReply(id, false), replyAll: (id) => GoingsOn.emailsCompose.openReply(id, true), forward: (...a) => GoingsOn.emailsCompose.openForward(...a), openDrafts: (...a) => GoingsOn.emailsCompose.openDraftsModal(...a), openDraft: (...a) => GoingsOn.emailsCompose.openDraft(...a), sendDraft: (...a) => GoingsOn.emailsCompose.sendDraft(...a), queueSend: (...a) => GoingsOn.emailsCompose.queueSend(...a), // Pagination goToPage, toggleSelection, selectAll, getSelected, clearSelected, // Expose managers selection: emailSelection, pagination: emailPagination, // Virtual scrolling (renderEmailItem lives in emails-render.js) renderEmailItem: (t, i) => GoingsOn.emailsRender.renderEmailItem(t, i), getScroller: () => emailScroller, }; })();