/**
* 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 `
${esc(e.subject)}${labelBadges ? ' ' + labelBadges : ''}
${esc(e.body.substring(0, 100))}...
`;
}
/**
* 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 = `
`;
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 = `
`;
} 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 `
`;
}).join('');
const subjectLine = isThread
? `${esc(email.subject)} (${threadEmails.length} messages)`
: esc(email.subject);
const content = `
${attachmentHtml}
${threadContent}
`;
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', '');
return;
}
const listHtml = drafts.map(d => {
const to = d.to || '(no recipient)';
const subject = d.subject || '(no subject)';
return `
${esc(subject)}
To: ${esc(to)} · ${d.receivedFormatted}
`;
}).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 = `
`;
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 = `
`;
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 => `
${r.snippet ? `
${esc(r.snippet)}
` : ''}
`).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,
};
})();