/**
* 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 = `