/** * 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.escapeAttr; // ============ 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', []); // ============ 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'); try { // Fetch pre-grouped threads from backend - fetch more for virtual scrolling const response = await GoingsOn.api.emails.listThreaded({ includeArchived: false, offset: 0, limit: 500, // Fetch more for virtual scrolling folder: activeFolder || null, label: activeLabel || null, }); // 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); // Phase 7 Tier 2 #9 — surface the count + 500-cap warning. _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', 'GoingsOn.emails.openCompose()', 'emails') : GoingsOn.ui.renderEmptyState('Set up an email account to get started.', 'Add Account', 'GoingsOn.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: renderEmailItem, getItems: () => GoingsOn.state.emailThreads, rowHeight: { estimated: 90, measure: true }, overscan: 5, }); } 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, }); } } /** * Render a single email item (for virtual scrolling). * @param {Object} thread - Thread object with mostRecentEmail * @param {number} index - Item index * @returns {string} HTML string */ function renderEmailItem(thread, index) { const e = thread.mostRecentEmail; // Use pre-computed field from backend (avoids JS date calculation) const isSnoozed = e.isSnoozed; const isSelected = emailSelection.isSelected(e.id); const threadBadge = thread.threadCount > 1 ? `${thread.threadCount}` : ''; const labelBadges = (e.labels || []).map(l => `${esc(l)}` ).join(''); return `
`; } /** * Open the email compose interface (new window on desktop, modal on mobile). */ async function openCompose() { // On mobile, compose in-app since Tauri doesn't support multiple windows if (GoingsOn.touch?.isTouchDevice) { openComposeModal(); return; } // Open compose in a new window using Tauri invoke try { await GoingsOn.api.window.openCompose(); } catch (err) { console.error('Failed to open compose window:', err); // Fall back to in-app modal openComposeModal(); } } // Modal compose controller — populated after openComposeModal mounts and // composeForm.bindBehaviors wires up the shared behaviors. let modalComposeCtrl = null; function openComposeModal(prefill) { modalComposeCtrl = null; const accounts = GoingsOn.getEmailAccountsCache(); const pf = prefill || {}; // Stage 3/4 of compose unification — markup and behaviors come from // composeForm. The chrome (modal wrapper, footer action row, Attach // button) stays modal-specific. CC/BCC sit after To, matching compose.html. // Pre-append signature for the default/selected account; bindBehaviors // tracks it so the first From-change knows what trailing block to strip. const selectedAccount = accounts.find(a => a.id === pf.accountId) || accounts[0]; const selectedAccountId = selectedAccount?.id || null; const sig = selectedAccount?.emailSignature || ''; const bodyWithSig = (pf.body || '') + (sig ? '\n\n-- \n' + sig : ''); const ccPrefill = pf.cc || ''; const bccPrefill = pf.bcc || ''; const ccBccExpanded = !!(ccPrefill || bccPrefill); const fieldsHtml = GoingsOn.composeForm.buildFieldsHtml({ accounts, selectedAccountId, prefill: { to: pf.to || '', cc: ccPrefill, bcc: bccPrefill, subject: pf.subject || '', body: bodyWithSig, }, showCcBcc: ccBccExpanded, showAttachments: true, bodyRows: 8, }); const replyContext = { inReplyTo: pf.inReplyTo || null, references: pf.references || null, threadId: pf.threadId || null, }; const content = `
${fieldsHtml}
`; GoingsOn.modal.openModal('Compose Email', content); setTimeout(() => { const form = document.getElementById('compose-modal-form'); if (!form) return; // Stage 4/5 — single bindBehaviors call owns autocomplete, // address highlight, CC/BCC toggle, signature swap, attachment // picker/render/remove, draft autosave, and reply indicator. modalComposeCtrl = GoingsOn.composeForm.bindBehaviors({ accounts, initialSignature: sig, getContacts: GoingsOn.autocomplete.getContacts, onError: (msg) => GoingsOn.ui.showToast(msg, 'error', { duration: 8000 }), enableAutosave: true, initialDraftId: pf.draftId || null, saveDraft: (input) => GoingsOn.api.emails.saveDraft(input), getReplyContext: () => replyContext, onDraftStatus: (kind, message) => { if (kind === 'error') { GoingsOn.ui.showToast(message, 'error', { duration: 6000 }); } else { GoingsOn.ui.showToast(message, 'success', { duration: 2500 }); } }, enableReplyIndicator: true, }); const attachBtn = document.getElementById('compose-modal-attach'); const cancelBtn = document.getElementById('compose-modal-cancel'); const sendBtn = document.getElementById('compose-modal-send'); const saveDraftBtn = document.getElementById('compose-modal-save-draft'); if (attachBtn) attachBtn.addEventListener('click', modalComposeCtrl.pickAttachment); if (saveDraftBtn) saveDraftBtn.addEventListener('click', () => modalComposeCtrl.saveDraftNow()); if (cancelBtn) cancelBtn.addEventListener('click', () => GoingsOn.ui.closeModal()); const submit = async () => { modalComposeCtrl.setSending(true); const accountSelect = document.getElementById('from-account'); const toEl = document.getElementById('to-address'); const ccEl = document.getElementById('cc-address'); const bccEl = document.getElementById('bcc-address'); const subjectEl = document.getElementById('subject'); const bodyEl = document.getElementById('body'); const attachedFiles = modalComposeCtrl.getAttachedFiles(); const input = GoingsOn.composeForm.collectInput({ accountId: accountSelect ? accountSelect.value : null, toAddress: toEl ? toEl.value : '', ccAddress: ccEl ? ccEl.value : '', bccAddress: bccEl ? bccEl.value : '', subject: subjectEl ? subjectEl.value : '', body: bodyEl ? bodyEl.value : '', attachedFiles, replyContext, }); const result = GoingsOn.composeForm.validateForSend(input, attachedFiles); if (!result.ok) { GoingsOn.ui.showToast(result.message, 'error', { duration: 8000 }); modalComposeCtrl.setSending(false); return; } GoingsOn.ui.closeModal(); queueSend({ input }); }; form.addEventListener('submit', (e) => { e.preventDefault(); submit(); }); if (sendBtn) sendBtn.addEventListener('click', (e) => { e.preventDefault(); submit(); }); }, 50); } async function markAllRead() { GoingsOn.cache.invalidate('emails'); await GoingsOn.ui.apiCall(GoingsOn.api.emails.markAllRead(), { successMessage: 'All emails marked as read!', errorMessage: 'Failed to mark emails as read', closeModal: false, reload: load, }); } /** * 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); return `
${directionIcon} ${esc(e.from)} ${dateStr}
${formattedBody}
`; }).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 }); load(); // Refresh to update read status } 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; GoingsOn.cache.invalidate('emails'); await GoingsOn.ui.apiCall(GoingsOn.api.emails.delete(id), { successMessage: 'Email deleted!', errorMessage: 'Failed to delete email', reload: load, }); } /** * Archive an email (also moves on IMAP server if available). * @param {string} id - Email ID to archive */ async function archive(id) { GoingsOn.cache.invalidate('emails'); await GoingsOn.ui.apiCall(GoingsOn.api.emails.archive(id), { successMessage: 'Email archived!', errorMessage: 'Failed to archive email', reload: load, }); } /** * Unarchive an email. * @param {string} id - Email ID to unarchive */ async function unarchive(id) { GoingsOn.cache.invalidate('emails'); await GoingsOn.ui.apiCall(GoingsOn.api.emails.unarchive(id), { successMessage: 'Email unarchived!', errorMessage: 'Failed to unarchive email', reload: load, }); } /** * Mark an email as read. * @param {string} id - Email ID */ async function markRead(id) { GoingsOn.cache.invalidate('emails'); await GoingsOn.ui.apiCall(GoingsOn.api.emails.markRead(id), { errorMessage: 'Failed to mark email as read', closeModal: false, reload: load, }); } /** * Mark an email as unread. * @param {string} id - Email ID */ async function markUnread(id) { GoingsOn.cache.invalidate('emails'); await GoingsOn.ui.apiCall(GoingsOn.api.emails.markUnread(id), { errorMessage: 'Failed to mark email as unread', closeModal: false, reload: load, }); } /** * 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 */ async function openReply(emailId, replyAll) { try { const email = await GoingsOn.api.emails.get(emailId); if (!email) return; // Determine the From account (match the account that received this email) const accountId = email.emailAccountId || ''; // Determine the To address let to; if (replyAll) { // Reply-All: sender + all recipients, minus our own address const accounts = GoingsOn.getEmailAccountsCache(); const ownAddresses = new Set(accounts.map(a => a.email.toLowerCase())); const allAddresses = []; // Add original sender const senderEmail = extractEmail(email.from); if (senderEmail && !ownAddresses.has(senderEmail.toLowerCase())) { allAddresses.push(senderEmail); } // Add original To recipients if (email.to) { email.to.split(',').map(a => extractEmail(a.trim())).forEach(addr => { if (addr && !ownAddresses.has(addr.toLowerCase()) && !allAddresses.includes(addr)) { allAddresses.push(addr); } }); } to = allAddresses.join(', '); } else { // Reply: just the sender to = extractEmail(email.from) || email.from; } // Build subject with Re: prefix (don't double-prefix) let subject = email.subject || ''; if (!subject.match(/^Re:/i)) { subject = 'Re: ' + subject; } // Build quoted body const date = new Date(email.receivedAt).toLocaleString(); const quotedLines = (email.body || '').split('\n').map(l => '> ' + l).join('\n'); const body = '\n\nOn ' + date + ', ' + email.from + ' wrote:\n>\n' + quotedLines; // Build References header chain let references = ''; if (email.messageId) { references = email.messageId; } // Open compose with reply context if (GoingsOn.touch?.isTouchDevice) { openComposeModal({ to, subject, body, inReplyTo: email.messageId || null, references: references || null, threadId: email.threadId || null, accountId, }); } else { await GoingsOn.api.window.openCompose({ to, subject, body, inReplyTo: email.messageId || null, references: references || null, threadId: email.threadId || null, accountId, }); } } catch (err) { GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to open reply'), 'error'); } } /** * Open compose window to forward an email. * @param {string} emailId - Email to forward */ async function openForward(emailId) { try { const email = await GoingsOn.api.emails.get(emailId); if (!email) return; const accountId = email.emailAccountId || ''; // Build subject with Fwd: prefix (don't double-prefix) let subject = email.subject || ''; if (!subject.match(/^Fwd:/i)) { subject = 'Fwd: ' + subject; } // Build forwarded body const date = new Date(email.receivedAt).toLocaleString(); const body = '\n\n---------- Forwarded message ----------\n' + 'From: ' + email.from + '\n' + 'Date: ' + date + '\n' + 'Subject: ' + (email.subject || '') + '\n' + 'To: ' + (email.to || '') + '\n\n' + (email.body || ''); if (GoingsOn.touch?.isTouchDevice) { openComposeModal({ to: '', subject, body, accountId }); } else { await GoingsOn.api.window.openCompose({ to: '', subject, body, accountId, }); } } catch (err) { GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to forward email'), 'error'); } } /** * 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. */ function extractEmail(addr) { if (!addr) return ''; const match = addr.match(/<([^>]+)>/); return match ? match[1] : addr.trim(); } /** * 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. // ============ Drafts ============ /** * Load and display drafts in a modal. */ async function openDraftsModal() { try { const drafts = await GoingsOn.api.emails.listDrafts(); if (drafts.length === 0) { GoingsOn.ui.openModal('Drafts', '

No drafts

'); return; } const listHtml = drafts.map(d => { const to = d.to || '(no recipient)'; const subject = d.subject || '(no subject)'; return `
`; }).join(''); GoingsOn.ui.openModal('Drafts', `
${listHtml}
`); } catch (err) { GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load drafts'), 'error'); } } /** * Open a draft for editing in the compose window. * @param {string} id - Draft email ID */ async function openDraft(id) { GoingsOn.ui.closeModal(); if (GoingsOn.touch?.isTouchDevice) { // Mobile: open compose modal with draft data try { const draft = await GoingsOn.api.emails.get(id); if (!draft) return; openComposeModal({ to: draft.to || '', cc: draft.ccAddress || '', bcc: draft.bccAddress || '', subject: draft.subject || '', body: draft.body || '', accountId: draft.draftAccountId || '', inReplyTo: draft.inReplyTo || null, threadId: draft.threadId || null, draftId: draft.id, }); } catch (err) { GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to open draft'), 'error'); } } else { try { await GoingsOn.api.window.openCompose({ draftId: id }); } catch (err) { GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to open draft'), 'error'); } } } /** * Send a draft directly. * @param {string} id - Draft email ID */ async function sendDraft(id) { try { await GoingsOn.api.emails.sendDraft(id); GoingsOn.ui.showToast('Draft sent!', 'success'); GoingsOn.ui.closeModal(); GoingsOn.cache.invalidate('emails'); load(); } catch (err) { GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to send draft'), 'error'); } } // ============ 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'); load(); 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'); load(); 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 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: renderEmailItem, 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'); } } // ============ Send-with-Delay (undo-send) ============ /** * Build a compose prefill object from a queued SendInput. * Note: attachmentPaths are NOT preserved through compose re-open today * (the compose UI uses a fresh file picker). When undo restores a message * that had attachments, surface a notice so the user re-attaches. */ function _sendInputToComposeParams(input) { return { to: input.to || input.toAddress || '', subject: input.subject || '', body: input.body || '', accountId: input.accountId || null, inReplyTo: input.inReplyTo || null, references: input.references || null, threadId: input.threadId || null, }; } /** * Queue an email send with an undo window. Returns immediately. The actual * `api.emails.send` call fires when the undo window expires; if the user * clicks Undo, the compose surface re-opens with the message restored. * * Charter rule: every irreversible operation gets an undo path (Phase 7 * Tier 1 #3 / Phase 3 #1). * * @param {Object} cfg * @param {Object} cfg.input - SendInput (accountId / to / subject / body / inReplyTo / references / threadId / attachmentPaths) * @param {number} [cfg.delaySeconds=5] - Undo window in seconds. */ function queueSend({ input, delaySeconds = 5 }) { const hadAttachments = Array.isArray(input.attachmentPaths) && input.attachmentPaths.length > 0; const timeout = Math.max(1000, delaySeconds * 1000); const message = input.inReplyTo ? 'Sending reply…' : 'Sending email…'; GoingsOn.ui.showUndoToast(message, { timeout, onConfirm: async () => { try { const result = await GoingsOn.api.emails.send(input); GoingsOn.ui.showToast('Email sent', 'success'); load(); if (result?.newImplicitContacts?.length > 0) { GoingsOn.autocomplete?.refresh?.(); for (const c of result.newImplicitContacts) { const name = c.displayName || c.display_name || ''; GoingsOn.ui.showToast(`Save ${name} as a contact?`, 'info', { duration: 8000, action: { label: 'Save', fn: async () => { try { await GoingsOn.api.contacts.promoteContact(c.id); GoingsOn.cache.invalidate('contacts'); GoingsOn.autocomplete?.refresh?.(); GoingsOn.ui.showToast(`${name} saved as contact`, 'success'); } catch (err) { GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to save contact'), 'error'); } }, }, }); } } } catch (err) { // User is no longer in compose; offer to re-open with the message. GoingsOn.ui.showToast( 'Send failed: ' + GoingsOn.utils.getErrorMessage(err), 'error', { duration: 12000, action: { label: 'Edit & retry', fn: () => { const prefill = _sendInputToComposeParams(input); if (GoingsOn.touch?.isTouchDevice) { openComposeModal(prefill); } else { GoingsOn.api.window.openCompose(prefill).catch(() => openComposeModal(prefill)); } }, }, } ); } }, onUndo: () => { const prefill = _sendInputToComposeParams(input); if (GoingsOn.touch?.isTouchDevice) { openComposeModal(prefill); } else { GoingsOn.api.window.openCompose(prefill).catch(() => openComposeModal(prefill)); } if (hadAttachments) { GoingsOn.ui.showToast('Re-attach your files — attachments cleared on undo.', 'info', { duration: 8000 }); } }, }); } // ============ Populate GoingsOn.emails Namespace ============ GoingsOn.emails = { load, openCompose, openComposeModal, markAllRead, open, delete: deleteEmail, archive, unarchive, markRead, markUnread, createTaskFromEmail, createEventFromEmail, createContactFromSender, openInBrowser, reply: (id) => openReply(id, false), replyAll: (id) => openReply(id, true), forward: openForward, openBlob, saveBlob, search: searchEmails, filterByFolder, filterByLabel, editLabels, _saveLabels: saveLabels, moveToFolder, _doMoveToFolder: doMoveToFolder, openDrafts: openDraftsModal, openDraft, sendDraft, queueSend, // Pagination goToPage, toggleSelection, selectAll, getSelected, clearSelected, // Expose managers selection: emailSelection, pagination: emailPagination, // Virtual scrolling renderEmailItem, getScroller: () => emailScroller, }; })();