Skip to main content

max / goingson

59.9 KB · 1424 lines History Blame Raw
1 /**
2 * GoingsOn - Emails Module
3 * Email list, compose, threading, actions (archive/delete/mark).
4 * Account management and OAuth live in email-accounts.js.
5 */
6
7 // ============ Emails Module ============
8
9 (function() {
10 'use strict';
11 const esc = GoingsOn.utils.escapeHtml;
12 const escAttr = GoingsOn.utils.escapeAttr;
13
14 // ============ Email Selection & Pagination ============
15
16 // Use new utility managers
17 const emailSelection = new GoingsOn.SelectionManager('email', '#email-list', 'email-bulk-actions');
18 const emailPagination = new GoingsOn.PaginationManager('email', GoingsOn.state.itemsPerPage);
19
20 // Legacy alias for backward compatibility
21 const selectedEmailIds = emailSelection.selectedIds;
22
23 // Virtual scroller instance
24 let emailScroller = null;
25
26 // Email threads stored in centralized state for virtual scrolling
27 GoingsOn.state.set('emailThreads', []);
28
29 // ============ Core Functions ============
30
31 /**
32 * Fetch threaded emails and render via virtual scroller.
33 */
34 async function load() {
35 if (GoingsOn.cache.isFresh('emails')) return;
36
37 // Phase 7 Tier 4 — pull active folder/label/search from URL on first
38 // load (e.g. after reload or deep-link). Subsequent filter changes
39 // are already URL-mirrored by the respective handlers.
40 restoreFiltersFromUrl();
41 const initialSearch = GoingsOn.queryState?.read('q');
42 if (initialSearch) {
43 searchEmails(initialSearch);
44 return;
45 }
46
47 const container = document.getElementById('email-list');
48 try {
49 // Fetch pre-grouped threads from backend - fetch more for virtual scrolling
50 const response = await GoingsOn.api.emails.listThreaded({
51 includeArchived: false,
52 offset: 0,
53 limit: 500, // Fetch more for virtual scrolling
54 folder: activeFolder || null,
55 label: activeLabel || null,
56 });
57
58 // Refresh filter dropdowns
59 loadFilters();
60
61 // Update cache with most recent emails
62 GoingsOn.state.set('emails', response.threads.map(t => t.mostRecentEmail));
63 GoingsOn.state.set('emailThreads', response.threads);
64
65 // Phase 7 Tier 2 #9 — surface the count + 500-cap warning.
66 _updateEmailCount(response.total, response.threads.length);
67
68 if (response.total === 0) {
69 const hasAccounts = GoingsOn.getEmailAccountsCache().length > 0;
70 container.innerHTML = hasAccounts
71 ? GoingsOn.ui.renderEmptyState('No emails yet.', 'Compose', 'GoingsOn.emails.openCompose()', 'emails')
72 : GoingsOn.ui.renderEmptyState('Set up an email account to get started.', 'Add Account', 'GoingsOn.emails.openAccountsModal()', 'inbox');
73 // Hide pagination
74 const paginationEl = document.getElementById('email-pagination');
75 if (paginationEl) paginationEl.classList.add('hidden');
76 // Destroy scroller
77 if (emailScroller) {
78 emailScroller.destroy();
79 emailScroller = null;
80 }
81 return;
82 }
83
84 // Update selection manager with current items for data-based range selection
85 emailSelection.setItems(response.threads.map(t => ({ id: t.mostRecentEmail.id })));
86
87 // Hide pagination - virtual scrolling replaces it
88 const paginationEl = document.getElementById('email-pagination');
89 if (paginationEl) paginationEl.classList.add('hidden');
90
91 // Initialize or refresh virtual scroller
92 if (!emailScroller) {
93 emailScroller = new GoingsOn.VirtualScroller({
94 container: container,
95 renderItem: renderEmailItem,
96 getItems: () => GoingsOn.state.emailThreads,
97 rowHeight: { estimated: 90, measure: true },
98 overscan: 5,
99 });
100 } else {
101 emailScroller.refresh();
102 }
103 GoingsOn.cache.markLoaded('emails');
104 } catch (err) {
105 container.innerHTML = `<div class="loading loading--error">Failed to load emails. <button class="btn-link" onclick="GoingsOn.emails.load()">Try again</button></div>`;
106 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load emails'), 'error', {
107 action: { label: 'Retry', fn: load },
108 duration: 8000,
109 });
110 }
111 }
112
113 /**
114 * Render a single email item (for virtual scrolling).
115 * @param {Object} thread - Thread object with mostRecentEmail
116 * @param {number} index - Item index
117 * @returns {string} HTML string
118 */
119 function renderEmailItem(thread, index) {
120 const e = thread.mostRecentEmail;
121 // Use pre-computed field from backend (avoids JS date calculation)
122 const isSnoozed = e.isSnoozed;
123 const isSelected = emailSelection.isSelected(e.id);
124 const threadBadge = thread.threadCount > 1
125 ? `<span class="thread-badge" title="${thread.threadCount} messages in thread">${thread.threadCount}</span>`
126 : '';
127 const labelBadges = (e.labels || []).map(l =>
128 `<span class="badge badge--xs badge--filled" data-color="blue">${esc(l)}</span>`
129 ).join('');
130
131 return `
132 <div class="email-item email-item-with-checkbox ${thread.hasUnread ? 'unread' : ''} ${isSnoozed ? 'email-snoozed' : ''}"
133 data-id="${escAttr(e.id)}"
134 oncontextmenu="GoingsOn.contextMenus.showEmail(event, '${escAttr(e.id)}')"
135 data-email-archived="${e.isArchived}" data-email-read="${e.isRead}"
136 data-email-snoozed="${isSnoozed}"
137 tabindex="0" role="listitem" aria-label="Email from ${esc(e.from)}">
138 <div class="email-checkbox-cell" onclick="event.stopPropagation();">
139 <input type="checkbox" class="bulk-checkbox" data-id="${escAttr(e.id)}"
140 ${isSelected ? 'checked' : ''}
141 onchange="GoingsOn.emails.toggleSelection('${escAttr(e.id)}', this, event)"
142 aria-label="Select email">
143 </div>
144 <div class="email-content" onclick="GoingsOn.emails.open('${escAttr(e.id)}')" role="button">
145 <div class="email-header">
146 <span class="email-from">${esc(e.from)}</span>
147 ${threadBadge}
148 ${isSnoozed ? `<span class="snooze-badge" title="Snoozed until ${escAttr(e.snoozedUntilFormatted || '')}" aria-label="Snoozed until ${escAttr(e.snoozedUntilFormatted || '')}">Snoozed</span>` : ''}
149 <span class="email-date">${e.receivedFormatted}</span>
150 </div>
151 <div class="email-subject">${esc(e.subject)}${labelBadges ? ' ' + labelBadges : ''}</div>
152 <div class="email-preview">${esc(e.body.substring(0, 100))}...</div>
153 </div>
154 <button class="btn-icon kebab-btn" onclick="event.stopPropagation(); GoingsOn.contextMenus.showEmail(event, '${escAttr(e.id)}')" title="Actions" aria-label="Email actions">&#x22EE;</button>
155 </div>
156 `;
157 }
158
159 /**
160 * Open the email compose interface (new window on desktop, modal on mobile).
161 */
162 async function openCompose() {
163 // On mobile, compose in-app since Tauri doesn't support multiple windows
164 if (GoingsOn.touch?.isTouchDevice) {
165 openComposeModal();
166 return;
167 }
168
169 // Open compose in a new window using Tauri invoke
170 try {
171 await GoingsOn.api.window.openCompose();
172 } catch (err) {
173 console.error('Failed to open compose window:', err);
174 // Fall back to in-app modal
175 openComposeModal();
176 }
177 }
178
179 // Modal compose controller — populated after openComposeModal mounts and
180 // composeForm.bindBehaviors wires up the shared behaviors.
181 let modalComposeCtrl = null;
182
183 function openComposeModal(prefill) {
184 modalComposeCtrl = null;
185 const accounts = GoingsOn.getEmailAccountsCache();
186 const pf = prefill || {};
187
188 // Stage 3/4 of compose unification — markup and behaviors come from
189 // composeForm. The chrome (modal wrapper, footer action row, Attach
190 // button) stays modal-specific. CC/BCC sit after To, matching compose.html.
191
192 // Pre-append signature for the default/selected account; bindBehaviors
193 // tracks it so the first From-change knows what trailing block to strip.
194 const selectedAccount = accounts.find(a => a.id === pf.accountId) || accounts[0];
195 const selectedAccountId = selectedAccount?.id || null;
196 const sig = selectedAccount?.emailSignature || '';
197 const bodyWithSig = (pf.body || '') + (sig ? '\n\n-- \n' + sig : '');
198
199 const ccPrefill = pf.cc || '';
200 const bccPrefill = pf.bcc || '';
201 const ccBccExpanded = !!(ccPrefill || bccPrefill);
202
203 const fieldsHtml = GoingsOn.composeForm.buildFieldsHtml({
204 accounts,
205 selectedAccountId,
206 prefill: {
207 to: pf.to || '',
208 cc: ccPrefill,
209 bcc: bccPrefill,
210 subject: pf.subject || '',
211 body: bodyWithSig,
212 },
213 showCcBcc: ccBccExpanded,
214 showAttachments: true,
215 bodyRows: 8,
216 });
217
218 const replyContext = {
219 inReplyTo: pf.inReplyTo || null,
220 references: pf.references || null,
221 threadId: pf.threadId || null,
222 };
223
224 const content = `
225 <form id="compose-modal-form" onsubmit="event.preventDefault(); return false;">
226 <span id="reply-indicator" class="reply-indicator"></span>
227 ${fieldsHtml}
228 <div class="form-actions" style="margin-top: 0.75rem;">
229 <button type="button" class="btn btn-secondary" id="compose-modal-attach">Attach File</button>
230 <button type="button" class="btn btn-secondary" id="compose-modal-save-draft">Save Draft</button>
231 <div class="flex-1"></div>
232 <button type="button" class="btn btn-secondary" id="compose-modal-cancel">Cancel</button>
233 <button type="submit" class="btn btn-primary" id="compose-modal-send">Send</button>
234 </div>
235 </form>
236 `;
237
238 GoingsOn.modal.openModal('Compose Email', content);
239
240 setTimeout(() => {
241 const form = document.getElementById('compose-modal-form');
242 if (!form) return;
243
244 // Stage 4/5 — single bindBehaviors call owns autocomplete,
245 // address highlight, CC/BCC toggle, signature swap, attachment
246 // picker/render/remove, draft autosave, and reply indicator.
247 modalComposeCtrl = GoingsOn.composeForm.bindBehaviors({
248 accounts,
249 initialSignature: sig,
250 getContacts: GoingsOn.autocomplete.getContacts,
251 onError: (msg) => GoingsOn.ui.showToast(msg, 'error', { duration: 8000 }),
252 enableAutosave: true,
253 initialDraftId: pf.draftId || null,
254 saveDraft: (input) => GoingsOn.api.emails.saveDraft(input),
255 getReplyContext: () => replyContext,
256 onDraftStatus: (kind, message) => {
257 if (kind === 'error') {
258 GoingsOn.ui.showToast(message, 'error', { duration: 6000 });
259 } else {
260 GoingsOn.ui.showToast(message, 'success', { duration: 2500 });
261 }
262 },
263 enableReplyIndicator: true,
264 });
265
266 const attachBtn = document.getElementById('compose-modal-attach');
267 const cancelBtn = document.getElementById('compose-modal-cancel');
268 const sendBtn = document.getElementById('compose-modal-send');
269 const saveDraftBtn = document.getElementById('compose-modal-save-draft');
270 if (attachBtn) attachBtn.addEventListener('click', modalComposeCtrl.pickAttachment);
271 if (saveDraftBtn) saveDraftBtn.addEventListener('click', () => modalComposeCtrl.saveDraftNow());
272 if (cancelBtn) cancelBtn.addEventListener('click', () => GoingsOn.ui.closeModal());
273
274 const submit = async () => {
275 modalComposeCtrl.setSending(true);
276 const accountSelect = document.getElementById('from-account');
277 const toEl = document.getElementById('to-address');
278 const ccEl = document.getElementById('cc-address');
279 const bccEl = document.getElementById('bcc-address');
280 const subjectEl = document.getElementById('subject');
281 const bodyEl = document.getElementById('body');
282 const attachedFiles = modalComposeCtrl.getAttachedFiles();
283 const input = GoingsOn.composeForm.collectInput({
284 accountId: accountSelect ? accountSelect.value : null,
285 toAddress: toEl ? toEl.value : '',
286 ccAddress: ccEl ? ccEl.value : '',
287 bccAddress: bccEl ? bccEl.value : '',
288 subject: subjectEl ? subjectEl.value : '',
289 body: bodyEl ? bodyEl.value : '',
290 attachedFiles,
291 replyContext,
292 });
293 const result = GoingsOn.composeForm.validateForSend(input, attachedFiles);
294 if (!result.ok) {
295 GoingsOn.ui.showToast(result.message, 'error', { duration: 8000 });
296 modalComposeCtrl.setSending(false);
297 return;
298 }
299 GoingsOn.ui.closeModal();
300 queueSend({ input });
301 };
302 form.addEventListener('submit', (e) => { e.preventDefault(); submit(); });
303 if (sendBtn) sendBtn.addEventListener('click', (e) => { e.preventDefault(); submit(); });
304 }, 50);
305 }
306
307 async function markAllRead() {
308 GoingsOn.cache.invalidate('emails');
309 await GoingsOn.ui.apiCall(GoingsOn.api.emails.markAllRead(), {
310 successMessage: 'All emails marked as read!',
311 errorMessage: 'Failed to mark emails as read',
312 closeModal: false,
313 reload: load,
314 });
315 }
316
317 /**
318 * Open an email in reader mode, loading its full thread if available.
319 * Marks the email as read and shows sender contact info.
320 * @param {string} id - Email ID to open
321 */
322 async function open(id) {
323 try {
324 const email = await GoingsOn.api.emails.get(id);
325 if (!email) return;
326
327 // Mark as read
328 await GoingsOn.api.emails.markRead(id);
329
330 // Check if this email is part of a thread
331 let threadEmails = [email];
332 if (email.threadId) {
333 try {
334 const thread = await GoingsOn.api.emails.listByThread(email.threadId);
335 if (thread && thread.length > 1) {
336 // Backend returns threads sorted by received_at ASC
337 threadEmails = thread;
338 }
339 } catch (e) {
340 console.error('Failed to load thread:', e);
341 }
342 }
343
344 const isThread = threadEmails.length > 1;
345
346 // Build action buttons for the most recent email
347 const latestEmail = threadEmails[threadEmails.length - 1];
348 const archiveBtn = latestEmail.isArchived
349 ? `<button class="btn btn-secondary" onclick="GoingsOn.emails.unarchive('${escAttr(latestEmail.id)}')">Unarchive</button>`
350 : `<button class="btn btn-secondary" onclick="GoingsOn.emails.archive('${escAttr(latestEmail.id)}')">Archive</button>`;
351
352 // Use pre-computed field from backend
353 const isSnoozed = latestEmail.isSnoozed;
354 const snoozeBtn = isSnoozed
355 ? `<button class="btn btn-secondary" onclick="GoingsOn.snooze.unsnooze('email', '${escAttr(latestEmail.id)}')">Unsnooze</button>`
356 : `<button class="btn btn-secondary" onclick="GoingsOn.snooze.openModal('email', '${escAttr(latestEmail.id)}')">Snooze</button>`;
357
358 // Look up contact from sender email
359 const parsed = GoingsOn.utils.parseEmailAddress(email.from);
360 let senderContact = null;
361 if (parsed.email) {
362 try {
363 senderContact = await GoingsOn.api.contacts.findByEmail(parsed.email);
364 } catch (e) {
365 console.error('Failed to look up contact:', e);
366 }
367 }
368
369 // Build sender contact card
370 let contactCardHtml = '';
371 if (senderContact) {
372 const initials = (senderContact.displayName || senderContact.display_name || '?')
373 .split(/\s+/).map(w => w[0]).join('').substring(0, 2).toUpperCase();
374 const company = senderContact.company ? esc(senderContact.company) : '';
375 contactCardHtml = `
376 <div class="email-sender-contact row-flex row-flex-2">
377 <div class="avatar avatar--sm">${initials}</div>
378 <div class="email-sender-info">
379 <span class="email-sender-name">${esc(senderContact.displayName || senderContact.display_name)}</span>
380 ${company ? `<span class="email-sender-company">${company}</span>` : ''}
381 </div>
382 <button class="btn btn-sm btn-secondary" onclick="GoingsOn.ui.closeModal(); GoingsOn.contacts.open('${escAttr(senderContact.id)}')">View Contact</button>
383 </div>
384 `;
385 } else if (parsed.email) {
386 contactCardHtml = `
387 <div class="email-sender-contact row-flex row-flex-2">
388 <div class="avatar avatar--sm avatar--unknown">?</div>
389 <div class="email-sender-info">
390 <span class="email-sender-name">${esc(parsed.name || parsed.email)}</span>
391 </div>
392 <button class="btn btn-sm btn-secondary" onclick="GoingsOn.emails.createContactFromSender('${escAttr(id)}')">+ Save Contact</button>
393 </div>
394 `;
395 }
396
397 // Build attachment panel from all emails in thread
398 const allAttachments = threadEmails.flatMap(e =>
399 (e.attachments || []).map(a => ({ ...a, emailFrom: e.from }))
400 );
401 let attachmentHtml = '';
402 if (allAttachments.length > 0) {
403 const attachmentItems = allAttachments.map(a => {
404 const icon = GoingsOn.attachments.getIcon(a.mimeType);
405 return `
406 <div class="email-attachment-row">
407 <span>${icon}</span>
408 <span class="attachment-filename email-attachment-name"
409 title="${escAttr(a.filename)}">${esc(a.filename)}</span>
410 <span class="email-attachment-size">${esc(a.sizeFormatted)}</span>
411 <button class="btn btn-sm btn-secondary" onclick="GoingsOn.emails.openBlob('${escAttr(a.blobHash)}', '${escAttr(a.filename)}')" title="Open">Open</button>
412 <button class="btn btn-sm btn-secondary" onclick="GoingsOn.emails.saveBlob('${escAttr(a.blobHash)}', '${escAttr(a.filename)}')" title="Save">Save</button>
413 </div>
414 `;
415 }).join('');
416
417 attachmentHtml = `
418 <div class="email-attachments-block">
419 <div class="email-attachments-heading">Attachments (${allAttachments.length})</div>
420 ${attachmentItems}
421 </div>
422 `;
423 }
424
425 // Render thread in forum style (oldest first)
426 const threadContent = threadEmails.map((e, index) => {
427 const isLatest = index === threadEmails.length - 1;
428 const dateStr = new Date(e.receivedAt).toLocaleString();
429 const directionIcon = e.isOutgoing ? '&#x2197;' : '&#x2199;'; // arrows for direction
430 const formattedBody = GoingsOn.utils.formatEmailBody(e.body);
431
432 return `
433 <div class="thread-message ${isLatest ? 'thread-message-latest' : ''}">
434 <div class="thread-message-header">
435 <span>${directionIcon} <span class="thread-message-from">${esc(e.from)}</span></span>
436 <span>${dateStr}</span>
437 </div>
438 <div class="email-reader-body">${formattedBody}</div>
439 </div>
440 `;
441 }).join('');
442
443 const subjectLine = isThread
444 ? `${esc(email.subject)} <span class="email-thread-count">(${threadEmails.length} messages)</span>`
445 : esc(email.subject);
446
447 const content = `
448 <div class="email-reader-container">
449 <div class="email-reader-header">
450 <div class="email-subject-line">${subjectLine}</div>
451 <div class="email-meta-line">
452 From: ${esc(email.from)}
453 ${email.isArchived ? ' · <em>Archived</em>' : ''}
454 ${email.sourceFolder ? ` · ${esc(email.sourceFolder)}` : ''}
455 ${(email.labels || []).length > 0 ? ' · ' + email.labels.map(l => `<span class="badge badge--xs badge--filled" data-color="blue">${esc(l)}</span>`).join(' ') : ''}
456 ${isSnoozed ? ` · <span class="email-snoozed-tag"><em>Snoozed until ${esc(latestEmail.snoozedUntilFormatted || '')}</em></span>` : ''}
457 </div>
458 ${contactCardHtml}
459 </div>
460 ${attachmentHtml}
461 <div class="email-reader-thread">
462 ${threadContent}
463 </div>
464 <div class="form-actions email-actions-bar">
465 <button class="btn btn-primary" onclick="GoingsOn.emails.reply('${escAttr(latestEmail.id)}')">Reply</button>
466 <button class="btn btn-secondary" onclick="GoingsOn.emails.replyAll('${escAttr(latestEmail.id)}')">Reply All</button>
467 <button class="btn btn-secondary" onclick="GoingsOn.emails.forward('${escAttr(latestEmail.id)}')">Forward</button>
468 <button class="btn btn-secondary text-accent-red" onclick="GoingsOn.emails.delete('${escAttr(latestEmail.id)}')">Delete</button>
469 ${archiveBtn}
470 ${snoozeBtn}
471 <button class="btn btn-secondary" onclick="GoingsOn.emails.createTaskFromEmail('${escAttr(latestEmail.id)}')">Create Task</button>
472 <div class="dropdown" style="position: relative;">
473 <button class="btn btn-secondary" onclick="this.nextElementSibling.classList.toggle('show')">
474 Actions ▾
475 </button>
476 <div class="dropdown-menu">
477 <button class="dropdown-item" onclick="GoingsOn.emails.createTaskFromEmail('${escAttr(latestEmail.id)}'); this.parentElement.classList.remove('show');">
478 Convert to Task
479 </button>
480 <button class="dropdown-item" onclick="GoingsOn.emails.createEventFromEmail('${escAttr(latestEmail.id)}'); this.parentElement.classList.remove('show');">
481 Convert to Event
482 </button>
483 <button class="dropdown-item" onclick="GoingsOn.emails.editLabels('${escAttr(latestEmail.id)}', ${escAttr(JSON.stringify(latestEmail.labels || []))}); this.parentElement.classList.remove('show');">
484 Edit Labels
485 </button>
486 <button class="dropdown-item" onclick="GoingsOn.emails.moveToFolder('${escAttr(latestEmail.id)}'); this.parentElement.classList.remove('show');">
487 Move to Folder
488 </button>
489 </div>
490 </div>
491 <div class="flex-1"></div>
492 <button class="btn btn-secondary" onclick="GoingsOn.emails.openInBrowser('${escAttr(latestEmail.id)}')" title="Open in browser">Open in Browser</button>
493 </div>
494 </div>
495 `;
496 GoingsOn.ui.openModal(isThread ? 'Thread' : 'Email', content, { large: true });
497 load(); // Refresh to update read status
498 } catch (err) {
499 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load email'), 'error');
500 }
501 }
502
503 /**
504 * Delete an email with confirmation dialog.
505 * @param {string} id - Email ID to delete
506 */
507 async function deleteEmail(id) {
508 if (!await GoingsOn.ui.confirmDelete('email')) return;
509
510 GoingsOn.cache.invalidate('emails');
511 await GoingsOn.ui.apiCall(GoingsOn.api.emails.delete(id), {
512 successMessage: 'Email deleted!',
513 errorMessage: 'Failed to delete email',
514 reload: load,
515 });
516 }
517
518 /**
519 * Archive an email (also moves on IMAP server if available).
520 * @param {string} id - Email ID to archive
521 */
522 async function archive(id) {
523 GoingsOn.cache.invalidate('emails');
524 await GoingsOn.ui.apiCall(GoingsOn.api.emails.archive(id), {
525 successMessage: 'Email archived!',
526 errorMessage: 'Failed to archive email',
527 reload: load,
528 });
529 }
530
531 /**
532 * Unarchive an email.
533 * @param {string} id - Email ID to unarchive
534 */
535 async function unarchive(id) {
536 GoingsOn.cache.invalidate('emails');
537 await GoingsOn.ui.apiCall(GoingsOn.api.emails.unarchive(id), {
538 successMessage: 'Email unarchived!',
539 errorMessage: 'Failed to unarchive email',
540 reload: load,
541 });
542 }
543
544 /**
545 * Mark an email as read.
546 * @param {string} id - Email ID
547 */
548 async function markRead(id) {
549 GoingsOn.cache.invalidate('emails');
550 await GoingsOn.ui.apiCall(GoingsOn.api.emails.markRead(id), {
551 errorMessage: 'Failed to mark email as read',
552 closeModal: false,
553 reload: load,
554 });
555 }
556
557 /**
558 * Mark an email as unread.
559 * @param {string} id - Email ID
560 */
561 async function markUnread(id) {
562 GoingsOn.cache.invalidate('emails');
563 await GoingsOn.ui.apiCall(GoingsOn.api.emails.markUnread(id), {
564 errorMessage: 'Failed to mark email as unread',
565 closeModal: false,
566 reload: load,
567 });
568 }
569
570 /**
571 * Create a new contact from an email's sender address.
572 * Parses the From field, creates the contact, and adds the email address.
573 * @param {string} emailId - Email ID to extract sender from
574 */
575 async function createContactFromSender(emailId) {
576 try {
577 const email = await GoingsOn.api.emails.get(emailId);
578 if (!email) {
579 GoingsOn.ui.showToast('Email not found', 'error');
580 return;
581 }
582
583 const parsed = GoingsOn.utils.parseEmailAddress(email.from);
584 if (!parsed.email) {
585 GoingsOn.ui.showToast('Could not parse email address', 'error');
586 return;
587 }
588
589 // Split parsed name into first/last for display name
590 const displayName = parsed.name || parsed.email.split('@')[0];
591
592 // Create the contact
593 const contact = await GoingsOn.api.contacts.create({
594 displayName: displayName,
595 });
596
597 // Add the email address to the new contact
598 await GoingsOn.api.contacts.addEmail(contact.id, {
599 address: parsed.email,
600 label: 'Work',
601 isPrimary: true,
602 });
603
604 // Refresh contacts cache
605 GoingsOn.cache.invalidate('contacts');
606 const contacts = await GoingsOn.api.contacts.list();
607 GoingsOn.state.set('contacts', contacts);
608
609 GoingsOn.ui.showToast('Contact saved!', 'success');
610
611 // Re-open the email reader to show the updated contact card
612 open(emailId);
613 } catch (err) {
614 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to create contact'), 'error');
615 }
616 }
617
618 /**
619 * Create a task from an email's subject and sender info.
620 * Auto-links the sender's contact if one exists.
621 * @param {string} emailId - Source email ID
622 */
623 async function createTaskFromEmail(emailId) {
624 try {
625 const email = await GoingsOn.api.emails.get(emailId);
626 if (!email) {
627 GoingsOn.ui.showToast('Email not found', 'error');
628 return;
629 }
630
631 // Auto-link contact from sender email
632 let contactId = null;
633 const parsed = GoingsOn.utils.parseEmailAddress(email.from);
634 if (parsed.email) {
635 try {
636 const contact = await GoingsOn.api.contacts.findByEmail(parsed.email);
637 if (contact) contactId = contact.id;
638 } catch (_) { /* optional — contact may not exist */ }
639 }
640
641 const taskData = {
642 description: email.subject,
643 projectId: email.projectId || null,
644 priority: 'Medium',
645 due: null,
646 tags: [],
647 recurrence: 'None',
648 sourceEmailId: emailId,
649 contactId: contactId,
650 };
651
652 await GoingsOn.api.tasks.create(taskData);
653 GoingsOn.ui.showToast('Task created from email!', 'success');
654 GoingsOn.ui.closeModal();
655 GoingsOn.cache.invalidate('tasks');
656 GoingsOn.tasks.load();
657 } catch (err) {
658 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to create task'), 'error');
659 }
660 }
661
662 /**
663 * Create a calendar event from an email's subject and body.
664 * Defaults to a 1-hour event starting at the next hour.
665 * @param {string} emailId - Source email ID
666 */
667 async function createEventFromEmail(emailId) {
668 try {
669 const email = await GoingsOn.api.emails.get(emailId);
670 if (!email) {
671 GoingsOn.ui.showToast('Email not found', 'error');
672 return;
673 }
674
675 // Auto-link contact from sender email
676 let contactId = null;
677 const parsed = GoingsOn.utils.parseEmailAddress(email.from);
678 if (parsed.email) {
679 try {
680 const contact = await GoingsOn.api.contacts.findByEmail(parsed.email);
681 if (contact) contactId = contact.id;
682 } catch (_) { /* optional — contact may not exist */ }
683 }
684
685 // Default to 1 hour from now, rounded to next hour
686 const now = new Date();
687 now.setMinutes(0, 0, 0);
688 now.setHours(now.getHours() + 1);
689 const startTime = now.toISOString().slice(0, 16); // Format for datetime-local
690
691 const endTime = new Date(now.getTime() + 60 * 60 * 1000); // 1 hour later
692 const endTimeStr = endTime.toISOString().slice(0, 16);
693
694 const eventData = {
695 title: email.subject,
696 projectId: email.projectId || null,
697 startTime: startTime,
698 endTime: endTimeStr,
699 location: '',
700 description: `From: ${email.from}\n\n${email.body.substring(0, 500)}${email.body.length > 500 ? '...' : ''}`,
701 isAllDay: false,
702 contactId: contactId,
703 };
704
705 await GoingsOn.api.events.create(eventData);
706 GoingsOn.ui.showToast('Event created from email!', 'success');
707 GoingsOn.ui.closeModal();
708 GoingsOn.cache.invalidate('events');
709 GoingsOn.events.load();
710 } catch (err) {
711 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to create event'), 'error');
712 }
713 }
714
715 /**
716 * Open compose window for a reply.
717 * @param {string} emailId - Email to reply to
718 * @param {boolean} replyAll - If true, include all recipients
719 */
720 async function openReply(emailId, replyAll) {
721 try {
722 const email = await GoingsOn.api.emails.get(emailId);
723 if (!email) return;
724
725 // Determine the From account (match the account that received this email)
726 const accountId = email.emailAccountId || '';
727
728 // Determine the To address
729 let to;
730 if (replyAll) {
731 // Reply-All: sender + all recipients, minus our own address
732 const accounts = GoingsOn.getEmailAccountsCache();
733 const ownAddresses = new Set(accounts.map(a => a.email.toLowerCase()));
734 const allAddresses = [];
735
736 // Add original sender
737 const senderEmail = extractEmail(email.from);
738 if (senderEmail && !ownAddresses.has(senderEmail.toLowerCase())) {
739 allAddresses.push(senderEmail);
740 }
741
742 // Add original To recipients
743 if (email.to) {
744 email.to.split(',').map(a => extractEmail(a.trim())).forEach(addr => {
745 if (addr && !ownAddresses.has(addr.toLowerCase()) && !allAddresses.includes(addr)) {
746 allAddresses.push(addr);
747 }
748 });
749 }
750
751 to = allAddresses.join(', ');
752 } else {
753 // Reply: just the sender
754 to = extractEmail(email.from) || email.from;
755 }
756
757 // Build subject with Re: prefix (don't double-prefix)
758 let subject = email.subject || '';
759 if (!subject.match(/^Re:/i)) {
760 subject = 'Re: ' + subject;
761 }
762
763 // Build quoted body
764 const date = new Date(email.receivedAt).toLocaleString();
765 const quotedLines = (email.body || '').split('\n').map(l => '> ' + l).join('\n');
766 const body = '\n\nOn ' + date + ', ' + email.from + ' wrote:\n>\n' + quotedLines;
767
768 // Build References header chain
769 let references = '';
770 if (email.messageId) {
771 references = email.messageId;
772 }
773
774 // Open compose with reply context
775 if (GoingsOn.touch?.isTouchDevice) {
776 openComposeModal({
777 to,
778 subject,
779 body,
780 inReplyTo: email.messageId || null,
781 references: references || null,
782 threadId: email.threadId || null,
783 accountId,
784 });
785 } else {
786 await GoingsOn.api.window.openCompose({
787 to,
788 subject,
789 body,
790 inReplyTo: email.messageId || null,
791 references: references || null,
792 threadId: email.threadId || null,
793 accountId,
794 });
795 }
796 } catch (err) {
797 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to open reply'), 'error');
798 }
799 }
800
801 /**
802 * Open compose window to forward an email.
803 * @param {string} emailId - Email to forward
804 */
805 async function openForward(emailId) {
806 try {
807 const email = await GoingsOn.api.emails.get(emailId);
808 if (!email) return;
809
810 const accountId = email.emailAccountId || '';
811
812 // Build subject with Fwd: prefix (don't double-prefix)
813 let subject = email.subject || '';
814 if (!subject.match(/^Fwd:/i)) {
815 subject = 'Fwd: ' + subject;
816 }
817
818 // Build forwarded body
819 const date = new Date(email.receivedAt).toLocaleString();
820 const body = '\n\n---------- Forwarded message ----------\n'
821 + 'From: ' + email.from + '\n'
822 + 'Date: ' + date + '\n'
823 + 'Subject: ' + (email.subject || '') + '\n'
824 + 'To: ' + (email.to || '') + '\n\n'
825 + (email.body || '');
826
827 if (GoingsOn.touch?.isTouchDevice) {
828 openComposeModal({ to: '', subject, body, accountId });
829 } else {
830 await GoingsOn.api.window.openCompose({
831 to: '',
832 subject,
833 body,
834 accountId,
835 });
836 }
837 } catch (err) {
838 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to forward email'), 'error');
839 }
840 }
841
842 /**
843 * Open an email attachment blob with the system default app.
844 * @param {string} blobHash - SHA-256 hash of the blob
845 * @param {string} filename - Original filename
846 */
847 async function openBlob(blobHash, filename) {
848 try {
849 await GoingsOn.api.attachments.openEmailBlob(blobHash, filename);
850 } catch (err) {
851 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to open attachment'), 'error');
852 }
853 }
854
855 /**
856 * Save an email attachment blob to a user-chosen location.
857 * @param {string} blobHash - SHA-256 hash of the blob
858 * @param {string} filename - Default filename for save dialog
859 */
860 async function saveBlob(blobHash, filename) {
861 try {
862 const { save } = window.__TAURI__.dialog;
863 const destination = await save({
864 defaultPath: filename,
865 title: 'Save attachment as',
866 });
867 if (!destination) return;
868
869 await GoingsOn.api.attachments.saveEmailBlob(blobHash, destination);
870 GoingsOn.ui.showToast('File saved!', 'success');
871 } catch (err) {
872 if (err && err.toString().includes('cancelled')) return;
873 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to save attachment'), 'error');
874 }
875 }
876
877 /**
878 * Extract bare email address from "Name <email>" format.
879 */
880 function extractEmail(addr) {
881 if (!addr) return '';
882 const match = addr.match(/<([^>]+)>/);
883 return match ? match[1] : addr.trim();
884 }
885
886 /**
887 * Render email HTML to a temp file and open in the system browser.
888 * @param {string} emailId - Email ID to open
889 */
890 async function openInBrowser(emailId) {
891 try {
892 await GoingsOn.api.window.openEmailInBrowser(emailId);
893 } catch (err) {
894 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to open email in browser'), 'error');
895 }
896 }
897
898 // Stage 4: the modal's attachment picker / render / remove are owned by
899 // composeForm.bindBehaviors. The local pickModalAttachment /
900 // renderModalAttachments / removeModalAttachment helpers and the
901 // modalAttachedFiles array are gone; access via modalComposeCtrl instead.
902
903 // ============ Drafts ============
904
905 /**
906 * Load and display drafts in a modal.
907 */
908 async function openDraftsModal() {
909 try {
910 const drafts = await GoingsOn.api.emails.listDrafts();
911
912 if (drafts.length === 0) {
913 GoingsOn.ui.openModal('Drafts', '<div class="empty-state empty-state--compact"><p class="empty-state-text">No drafts</p></div>');
914 return;
915 }
916
917 const listHtml = drafts.map(d => {
918 const to = d.to || '(no recipient)';
919 const subject = d.subject || '(no subject)';
920 return `
921 <div class="email-item email-draft-item"
922 onclick="GoingsOn.emails.openDraft('${escAttr(d.id)}')">
923 <div class="email-draft-subject">${esc(subject)}</div>
924 <div class="email-draft-meta">To: ${esc(to)} · ${d.receivedFormatted}</div>
925 </div>
926 `;
927 }).join('');
928
929 GoingsOn.ui.openModal('Drafts', `<div>${listHtml}</div>`);
930 } catch (err) {
931 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load drafts'), 'error');
932 }
933 }
934
935 /**
936 * Open a draft for editing in the compose window.
937 * @param {string} id - Draft email ID
938 */
939 async function openDraft(id) {
940 GoingsOn.ui.closeModal();
941 if (GoingsOn.touch?.isTouchDevice) {
942 // Mobile: open compose modal with draft data
943 try {
944 const draft = await GoingsOn.api.emails.get(id);
945 if (!draft) return;
946 openComposeModal({
947 to: draft.to || '',
948 cc: draft.ccAddress || '',
949 bcc: draft.bccAddress || '',
950 subject: draft.subject || '',
951 body: draft.body || '',
952 accountId: draft.draftAccountId || '',
953 inReplyTo: draft.inReplyTo || null,
954 threadId: draft.threadId || null,
955 draftId: draft.id,
956 });
957 } catch (err) {
958 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to open draft'), 'error');
959 }
960 } else {
961 try {
962 await GoingsOn.api.window.openCompose({ draftId: id });
963 } catch (err) {
964 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to open draft'), 'error');
965 }
966 }
967 }
968
969 /**
970 * Send a draft directly.
971 * @param {string} id - Draft email ID
972 */
973 async function sendDraft(id) {
974 try {
975 await GoingsOn.api.emails.sendDraft(id);
976 GoingsOn.ui.showToast('Draft sent!', 'success');
977 GoingsOn.ui.closeModal();
978 GoingsOn.cache.invalidate('emails');
979 load();
980 } catch (err) {
981 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to send draft'), 'error');
982 }
983 }
984
985 // ============ Folder & Label Filters ============
986
987 let activeFolder = '';
988 let activeLabel = '';
989
990 async function loadFilters() {
991 try {
992 const [folders, labels] = await Promise.all([
993 GoingsOn.api.emails.listFolders(),
994 GoingsOn.api.emails.listLabels(),
995 ]);
996
997 const folderSelect = document.getElementById('email-folder-filter');
998 if (folderSelect) {
999 const current = folderSelect.value;
1000 folderSelect.innerHTML = '<option value="">All folders</option>' +
1001 folders.map(f => `<option value="${escAttr(f)}" ${f === current ? 'selected' : ''}>${esc(f)}</option>`).join('');
1002 }
1003
1004 const labelSelect = document.getElementById('email-label-filter');
1005 if (labelSelect) {
1006 const current = labelSelect.value;
1007 labelSelect.innerHTML = '<option value="">All labels</option>' +
1008 labels.map(l => `<option value="${escAttr(l)}" ${l === current ? 'selected' : ''}>${esc(l)}</option>`).join('');
1009 }
1010 } catch (_) { /* filters are optional */ }
1011 }
1012
1013 function filterByFolder(folder) {
1014 activeFolder = folder;
1015 GoingsOn.queryState?.write('folder', folder);
1016 clearSelectionIfAny();
1017 GoingsOn.cache.invalidate('emails');
1018 load();
1019 }
1020
1021 function filterByLabel(label) {
1022 activeLabel = label;
1023 GoingsOn.queryState?.write('label', label);
1024 clearSelectionIfAny();
1025 GoingsOn.cache.invalidate('emails');
1026 load();
1027 }
1028
1029 /**
1030 * Phase 7 Tier 4 — restore folder / label / search from URL on init.
1031 * Called once at first load; subsequent filter changes write back.
1032 */
1033 function restoreFiltersFromUrl() {
1034 if (!GoingsOn.queryState) return;
1035 const q = GoingsOn.queryState.readMany(['folder', 'label', 'q']);
1036 if (q.folder) {
1037 activeFolder = q.folder;
1038 const sel = document.getElementById('email-folder-filter');
1039 if (sel) sel.value = q.folder;
1040 }
1041 if (q.label) {
1042 activeLabel = q.label;
1043 const sel = document.getElementById('email-label-filter');
1044 if (sel) sel.value = q.label;
1045 }
1046 if (q.q) {
1047 const input = document.getElementById('email-search');
1048 if (input) input.value = q.q;
1049 }
1050 }
1051
1052 // Charter rule (Phase 2 #1): selection clears on filter change so bulk
1053 // actions can't target rows the user can no longer see.
1054 function clearSelectionIfAny() {
1055 if (selectedEmailIds.size > 0) {
1056 emailSelection.clear();
1057 GoingsOn.bulk?.updateBar?.();
1058 }
1059 }
1060
1061 /**
1062 * Open a modal to edit labels on an email.
1063 * @param {string} emailId - Email ID
1064 * @param {string[]} currentLabels - Current labels
1065 */
1066 async function editLabels(emailId, currentLabels) {
1067 const existing = await GoingsOn.api.emails.listLabels();
1068 const content = `
1069 <form id="label-form" onsubmit="event.preventDefault(); GoingsOn.emails._saveLabels('${escAttr(emailId)}');">
1070 <div class="form-group">
1071 <label class="form-label">Labels (comma-separated)</label>
1072 <input type="text" class="form-input" id="label-input" value="${escAttr((currentLabels || []).join(', '))}"
1073 placeholder="work, important, follow-up" autofocus>
1074 ${existing.length > 0 ? `<div class="label-existing-line">Existing: ${existing.map(l => esc(l)).join(', ')}</div>` : ''}
1075 </div>
1076 <div class="form-actions">
1077 <button type="button" class="btn btn-secondary" onclick="GoingsOn.ui.closeModal()">Cancel</button>
1078 <button type="submit" class="btn btn-primary">Save</button>
1079 </div>
1080 </form>
1081 `;
1082 GoingsOn.ui.openModal('Edit Labels', content);
1083 }
1084
1085 async function saveLabels(emailId) {
1086 const input = document.getElementById('label-input');
1087 const labels = input.value.split(',').map(s => s.trim()).filter(Boolean);
1088 try {
1089 await GoingsOn.api.emails.setLabels(emailId, labels);
1090 GoingsOn.ui.showToast('Labels updated!', 'success');
1091 GoingsOn.ui.closeModal();
1092 GoingsOn.cache.invalidate('emails');
1093 load();
1094 loadFilters();
1095 } catch (err) {
1096 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to update labels'), 'error');
1097 }
1098 }
1099
1100 /**
1101 * Move an email to a different folder.
1102 * @param {string} emailId - Email ID
1103 */
1104 async function moveToFolder(emailId) {
1105 try {
1106 const folders = await GoingsOn.api.emails.listFolders();
1107 // Also try to get IMAP folders from account
1108 const content = `
1109 <form onsubmit="event.preventDefault(); GoingsOn.emails._doMoveToFolder('${escAttr(emailId)}');">
1110 <div class="form-group">
1111 <label class="form-label">Move to folder</label>
1112 <input type="text" class="form-input" id="move-folder-input" placeholder="INBOX, Archive, Sent, ..."
1113 list="folder-suggestions" autofocus>
1114 <datalist id="folder-suggestions">
1115 ${folders.map(f => `<option value="${escAttr(f)}">`).join('')}
1116 </datalist>
1117 </div>
1118 <div class="form-actions">
1119 <button type="button" class="btn btn-secondary" onclick="GoingsOn.ui.closeModal()">Cancel</button>
1120 <button type="submit" class="btn btn-primary">Move</button>
1121 </div>
1122 </form>
1123 `;
1124 GoingsOn.ui.openModal('Move to Folder', content);
1125 } catch (err) {
1126 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load folders'), 'error');
1127 }
1128 }
1129
1130 async function doMoveToFolder(emailId) {
1131 const input = document.getElementById('move-folder-input');
1132 const folder = input.value.trim();
1133 if (!folder) return;
1134 try {
1135 await GoingsOn.api.emails.moveToFolder(emailId, folder);
1136 GoingsOn.ui.showToast(`Moved to ${folder}`, 'success');
1137 GoingsOn.ui.closeModal();
1138 GoingsOn.cache.invalidate('emails');
1139 load();
1140 loadFilters();
1141 } catch (err) {
1142 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to move email'), 'error');
1143 }
1144 }
1145
1146 // ============ Email Search ============
1147
1148 let searchDebounceTimer = null;
1149
1150 /**
1151 * Search emails using FTS5 backend.
1152 * @param {string} query - Search query text
1153 */
1154 function searchEmails(query) {
1155 clearTimeout(searchDebounceTimer);
1156 const trimmed = (query || '').trim();
1157 clearSelectionIfAny();
1158 GoingsOn.queryState?.write('q', trimmed);
1159
1160 if (!trimmed) {
1161 // Clear search — reload normal email list
1162 GoingsOn.cache.invalidate('emails');
1163 load();
1164 return;
1165 }
1166
1167 searchDebounceTimer = setTimeout(async () => {
1168 try {
1169 const response = await GoingsOn.api.search.query({
1170 query: trimmed,
1171 type: 'email',
1172 limit: 100,
1173 });
1174
1175 const container = document.getElementById('email-list');
1176
1177 if (response.results.length === 0) {
1178 if (emailScroller) { emailScroller.destroy(); emailScroller = null; }
1179 container.innerHTML = `<div class="loading search-no-results">No emails matching "${esc(trimmed)}"</div>`;
1180 return;
1181 }
1182
1183 // Fetch full email data for each result
1184 const emailIds = new Set(response.results.map(r => r.id));
1185 const allThreads = GoingsOn.state.emailThreads || [];
1186 const matchingThreads = allThreads.filter(t => emailIds.has(t.mostRecentEmail.id));
1187
1188 // If we have cached threads, filter them; otherwise show search results directly
1189 if (matchingThreads.length > 0) {
1190 GoingsOn.state.set('emailThreads', matchingThreads);
1191 if (emailScroller) {
1192 emailScroller.refresh();
1193 } else {
1194 emailScroller = new GoingsOn.VirtualScroller({
1195 container: container,
1196 renderItem: renderEmailItem,
1197 getItems: () => GoingsOn.state.emailThreads,
1198 rowHeight: { estimated: 90, measure: true },
1199 overscan: 5,
1200 });
1201 }
1202 } else {
1203 // Render search results as simple list
1204 if (emailScroller) { emailScroller.destroy(); emailScroller = null; }
1205 container.innerHTML = response.results.map(r => `
1206 <div class="email-item" data-id="${escAttr(r.id)}"
1207 onclick="GoingsOn.emails.open('${escAttr(r.id)}')"
1208 tabindex="0" role="listitem">
1209 <div class="email-content">
1210 <div class="email-header">
1211 <span class="email-subject">${esc(r.title)}</span>
1212 </div>
1213 ${r.snippet ? `<div class="email-preview">${esc(r.snippet)}</div>` : ''}
1214 </div>
1215 </div>
1216 `).join('');
1217 }
1218 } catch (err) {
1219 console.error('Email search failed:', err);
1220 }
1221 }, 250);
1222 }
1223
1224 // ============ Pagination ============
1225 // Account management, OAuth, and sync live in email-accounts.js
1226
1227 function goToPage(direction) {
1228 emailPagination.goToPage(direction);
1229 load();
1230 }
1231
1232 // ============ Email Selection ============
1233
1234 function toggleSelection(id, checkbox, event) {
1235 emailSelection.toggle(id, checkbox, event);
1236 }
1237
1238 function selectAll() {
1239 emailSelection.selectAll();
1240 }
1241
1242 function getSelected() {
1243 return emailSelection.getSelected();
1244 }
1245
1246 function clearSelected() {
1247 emailSelection.clear();
1248 }
1249
1250 /**
1251 * Update the "N emails" count chip. Note: total is at thread granularity,
1252 * shown is also at thread granularity, since the filter bar describes the
1253 * visible list which renders one row per thread.
1254 */
1255 function _updateEmailCount(total, shown) {
1256 const el = document.getElementById('email-count');
1257 if (!el) return;
1258 if (typeof total !== 'number' || total < 0) {
1259 el.textContent = '';
1260 el.classList.remove('filter-count--capped');
1261 return;
1262 }
1263 const noun = total === 1 ? 'thread' : 'threads';
1264 if (shown < total) {
1265 el.textContent = `${shown} of ${total} ${noun} — narrow with filters`;
1266 el.classList.add('filter-count--capped');
1267 } else {
1268 el.textContent = `${total} ${noun}`;
1269 el.classList.remove('filter-count--capped');
1270 }
1271 }
1272
1273 // ============ Send-with-Delay (undo-send) ============
1274
1275 /**
1276 * Build a compose prefill object from a queued SendInput.
1277 * Note: attachmentPaths are NOT preserved through compose re-open today
1278 * (the compose UI uses a fresh file picker). When undo restores a message
1279 * that had attachments, surface a notice so the user re-attaches.
1280 */
1281 function _sendInputToComposeParams(input) {
1282 return {
1283 to: input.to || input.toAddress || '',
1284 subject: input.subject || '',
1285 body: input.body || '',
1286 accountId: input.accountId || null,
1287 inReplyTo: input.inReplyTo || null,
1288 references: input.references || null,
1289 threadId: input.threadId || null,
1290 };
1291 }
1292
1293 /**
1294 * Queue an email send with an undo window. Returns immediately. The actual
1295 * `api.emails.send` call fires when the undo window expires; if the user
1296 * clicks Undo, the compose surface re-opens with the message restored.
1297 *
1298 * Charter rule: every irreversible operation gets an undo path (Phase 7
1299 * Tier 1 #3 / Phase 3 #1).
1300 *
1301 * @param {Object} cfg
1302 * @param {Object} cfg.input - SendInput (accountId / to / subject / body / inReplyTo / references / threadId / attachmentPaths)
1303 * @param {number} [cfg.delaySeconds=5] - Undo window in seconds.
1304 */
1305 function queueSend({ input, delaySeconds = 5 }) {
1306 const hadAttachments = Array.isArray(input.attachmentPaths) && input.attachmentPaths.length > 0;
1307 const timeout = Math.max(1000, delaySeconds * 1000);
1308 const message = input.inReplyTo ? 'Sending reply…' : 'Sending email…';
1309
1310 GoingsOn.ui.showUndoToast(message, {
1311 timeout,
1312 onConfirm: async () => {
1313 try {
1314 const result = await GoingsOn.api.emails.send(input);
1315 GoingsOn.ui.showToast('Email sent', 'success');
1316 load();
1317
1318 if (result?.newImplicitContacts?.length > 0) {
1319 GoingsOn.autocomplete?.refresh?.();
1320 for (const c of result.newImplicitContacts) {
1321 const name = c.displayName || c.display_name || '';
1322 GoingsOn.ui.showToast(`Save ${name} as a contact?`, 'info', {
1323 duration: 8000,
1324 action: {
1325 label: 'Save',
1326 fn: async () => {
1327 try {
1328 await GoingsOn.api.contacts.promoteContact(c.id);
1329 GoingsOn.cache.invalidate('contacts');
1330 GoingsOn.autocomplete?.refresh?.();
1331 GoingsOn.ui.showToast(`${name} saved as contact`, 'success');
1332 } catch (err) {
1333 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to save contact'), 'error');
1334 }
1335 },
1336 },
1337 });
1338 }
1339 }
1340 } catch (err) {
1341 // User is no longer in compose; offer to re-open with the message.
1342 GoingsOn.ui.showToast(
1343 'Send failed: ' + GoingsOn.utils.getErrorMessage(err),
1344 'error',
1345 {
1346 duration: 12000,
1347 action: {
1348 label: 'Edit & retry',
1349 fn: () => {
1350 const prefill = _sendInputToComposeParams(input);
1351 if (GoingsOn.touch?.isTouchDevice) {
1352 openComposeModal(prefill);
1353 } else {
1354 GoingsOn.api.window.openCompose(prefill).catch(() => openComposeModal(prefill));
1355 }
1356 },
1357 },
1358 }
1359 );
1360 }
1361 },
1362 onUndo: () => {
1363 const prefill = _sendInputToComposeParams(input);
1364 if (GoingsOn.touch?.isTouchDevice) {
1365 openComposeModal(prefill);
1366 } else {
1367 GoingsOn.api.window.openCompose(prefill).catch(() => openComposeModal(prefill));
1368 }
1369 if (hadAttachments) {
1370 GoingsOn.ui.showToast('Re-attach your files — attachments cleared on undo.', 'info', { duration: 8000 });
1371 }
1372 },
1373 });
1374 }
1375
1376 // ============ Populate GoingsOn.emails Namespace ============
1377
1378 GoingsOn.emails = {
1379 load,
1380 openCompose,
1381 openComposeModal,
1382 markAllRead,
1383 open,
1384 delete: deleteEmail,
1385 archive,
1386 unarchive,
1387 markRead,
1388 markUnread,
1389 createTaskFromEmail,
1390 createEventFromEmail,
1391 createContactFromSender,
1392 openInBrowser,
1393 reply: (id) => openReply(id, false),
1394 replyAll: (id) => openReply(id, true),
1395 forward: openForward,
1396 openBlob,
1397 saveBlob,
1398 search: searchEmails,
1399 filterByFolder,
1400 filterByLabel,
1401 editLabels,
1402 _saveLabels: saveLabels,
1403 moveToFolder,
1404 _doMoveToFolder: doMoveToFolder,
1405 openDrafts: openDraftsModal,
1406 openDraft,
1407 sendDraft,
1408 queueSend,
1409 // Pagination
1410 goToPage,
1411 toggleSelection,
1412 selectAll,
1413 getSelected,
1414 clearSelected,
1415 // Expose managers
1416 selection: emailSelection,
1417 pagination: emailPagination,
1418 // Virtual scrolling
1419 renderEmailItem,
1420 getScroller: () => emailScroller,
1421 };
1422
1423 })();
1424