Skip to main content

max / goingson

47.2 KB · 1096 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.escapeAttrValue;
13 const escArg = GoingsOn.utils.escapeHandlerArg;
14 const escAttrVal = GoingsOn.utils.escapeAttrValue;
15
16 // ============ Email Selection & Pagination ============
17
18 // Use new utility managers
19 const emailSelection = new GoingsOn.SelectionManager('email', '#email-list', 'email-bulk-actions');
20 const emailPagination = new GoingsOn.PaginationManager('email', GoingsOn.state.itemsPerPage);
21
22 // Legacy alias for backward compatibility
23 const selectedEmailIds = emailSelection.selectedIds;
24
25 // Virtual scroller instance
26 let emailScroller = null;
27
28 // Email threads stored in centralized state for virtual scrolling
29 GoingsOn.state.set('emailThreads', []);
30
31 // CHRONIC-E structural fix (ultra-fuzz Run #28 S3/S4). Reading an email — the
32 // single most frequent action in the app — used to refetch the whole 500-thread
33 // window. Wire one subscriber so a surgical state.set('emailThreads', …)
34 // re-renders the list in place. `suppressEmailRender` gates it during the full
35 // rebuild in load()/searchEmails so set() there doesn't refresh a scroller that
36 // is about to be (re)created on the next line.
37 let suppressEmailRender = false;
38 GoingsOn.state.subscribe('emailThreads', () => {
39 if (suppressEmailRender) return;
40 if (emailScroller) emailScroller.refresh();
41 });
42
43 // ============ Surgical single-thread list mutations (CHRONIC-E) ============
44 // Each mutates state.emailThreads in place and lets the subscriber refresh the
45 // scroller — replacing the old `reload: load` that refetched 500 threads on
46 // every single-email action (ultra-fuzz Run #28 S4). Threads are matched by
47 // their representative (most-recent) email id, which is the id the list rows
48 // and the reader action bar operate on.
49
50 /** Drop the thread whose representative email is `emailId` from the list. */
51 function _removeThread(emailId) {
52 const threads = GoingsOn.state.emailThreads || [];
53 GoingsOn.state.set('emailThreads', threads.filter(t => t.mostRecentEmail.id !== emailId));
54 }
55
56 /** Toggle a thread's read state. Mirrors the bulk markRead pattern. */
57 function _setThreadRead(emailId, read) {
58 const threads = GoingsOn.state.emailThreads || [];
59 GoingsOn.state.set('emailThreads', threads.map(t =>
60 t.mostRecentEmail.id === emailId
61 ? { ...t, mostRecentEmail: { ...t.mostRecentEmail, isRead: read }, hasUnread: !read }
62 : t
63 ));
64 }
65
66 /**
67 * Mark the opened message read and recompute the thread's unread flag from the
68 * thread we just loaded. hasUnread is cleared only when no *other* message in
69 * the thread is still unread, so a multi-message thread with remaining unread
70 * keeps its dot rather than wrongly showing read (GO todo hazard note).
71 */
72 function _markThreadReadForEmail(emailId, threadEmails) {
73 const threads = GoingsOn.state.emailThreads || [];
74 const stillUnread = (threadEmails || []).some(e => e.id !== emailId && !e.isRead);
75 let changed = false;
76 const next = threads.map(t => {
77 if (t.mostRecentEmail.id !== emailId) return t;
78 changed = true;
79 return { ...t, mostRecentEmail: { ...t.mostRecentEmail, isRead: true }, hasUnread: stillUnread };
80 });
81 if (changed) GoingsOn.state.set('emailThreads', next);
82 }
83
84 // Incremental pagination. The thread list streams in pages via the scroller's
85 // onNeedMore hook instead of capping at a fixed 500 rows, so a large mailbox's
86 // tail is reachable (ultra-fuzz Run #28 S4). baseFilters holds the current
87 // folder/label so each page request stays consistent.
88 const EMAIL_PAGE_SIZE = 200;
89 const emailPaging = { loadedCount: 0, total: 0, baseFilters: null };
90
91 /**
92 * Fetch and append the next page of threads. Wired to the scroller's
93 * onNeedMore hook. No-ops once every thread is loaded; on error it surfaces a
94 * toast and stops paging rather than spinning.
95 */
96 async function loadMoreEmails() {
97 if (emailPaging.loadedCount >= emailPaging.total || !emailPaging.baseFilters) return;
98 let response;
99 try {
100 response = await GoingsOn.api.emails.listThreaded({
101 ...emailPaging.baseFilters,
102 offset: emailPaging.loadedCount,
103 limit: EMAIL_PAGE_SIZE,
104 });
105 } catch (err) {
106 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load more emails'), 'error');
107 return;
108 }
109 const threads = (GoingsOn.state.emailThreads || []).concat(response.threads);
110 GoingsOn.state.set('emails', threads.map(t => t.mostRecentEmail));
111 GoingsOn.state.set('emailThreads', threads);
112 emailPaging.loadedCount = threads.length;
113 emailPaging.total = response.total;
114 emailSelection.setItems(threads.map(t => ({ id: t.mostRecentEmail.id })));
115 _updateEmailCount(emailPaging.total, emailPaging.loadedCount);
116 // Re-arms onNeedMore so the next page can load on further scroll.
117 if (emailScroller) emailScroller.refresh();
118 }
119
120 // ============ Core Functions ============
121
122 /**
123 * Fetch threaded emails and render via virtual scroller.
124 */
125 async function load() {
126 if (GoingsOn.cache.isFresh('emails')) return;
127
128 // Phase 7 Tier 4 — pull active folder/label/search from URL on first
129 // load (e.g. after reload or deep-link). Subsequent filter changes
130 // are already URL-mirrored by the respective handlers.
131 restoreFiltersFromUrl();
132 const initialSearch = GoingsOn.queryState?.read('q');
133 if (initialSearch) {
134 searchEmails(initialSearch);
135 return;
136 }
137
138 const container = document.getElementById('email-list');
139 // Suppress the state subscriber for the whole rebuild (see subscribe() above).
140 suppressEmailRender = true;
141 // Reset paging for the current folder/label and fetch the first page; the
142 // list streams in later pages via the scroller's onNeedMore hook.
143 const baseFilters = {
144 includeArchived: false,
145 folder: activeFolder || null,
146 label: activeLabel || null,
147 };
148 emailPaging.baseFilters = baseFilters;
149 emailPaging.loadedCount = 0;
150 emailPaging.total = 0;
151 try {
152 // Fetch the first page of pre-grouped threads from the backend.
153 const response = await GoingsOn.api.emails.listThreaded({
154 ...baseFilters,
155 offset: 0,
156 limit: EMAIL_PAGE_SIZE,
157 });
158
159 // Refresh filter dropdowns
160 loadFilters();
161
162 // Update cache with most recent emails
163 GoingsOn.state.set('emails', response.threads.map(t => t.mostRecentEmail));
164 GoingsOn.state.set('emailThreads', response.threads);
165 emailPaging.loadedCount = response.threads.length;
166 emailPaging.total = response.total;
167
168 // Phase 7 Tier 2 #9 — surface the count; reads "X of N" while later
169 // pages stream in on scroll.
170 _updateEmailCount(response.total, response.threads.length);
171
172 if (response.total === 0) {
173 const hasAccounts = GoingsOn.getEmailAccountsCache().length > 0;
174 container.innerHTML = hasAccounts
175 ? GoingsOn.ui.renderEmptyState('No emails yet.', 'Compose', 'emails.openCompose', 'emails')
176 : GoingsOn.ui.renderEmptyState('Set up an email account to get started.', 'Add Account', 'emails.openAccountsModal', 'inbox');
177 // Hide pagination
178 const paginationEl = document.getElementById('email-pagination');
179 if (paginationEl) paginationEl.classList.add('hidden');
180 // Destroy scroller
181 if (emailScroller) {
182 emailScroller.destroy();
183 emailScroller = null;
184 }
185 return;
186 }
187
188 // Update selection manager with current items for data-based range selection
189 emailSelection.setItems(response.threads.map(t => ({ id: t.mostRecentEmail.id })));
190
191 // Hide pagination - virtual scrolling replaces it
192 const paginationEl = document.getElementById('email-pagination');
193 if (paginationEl) paginationEl.classList.add('hidden');
194
195 // Initialize or refresh virtual scroller
196 if (!emailScroller) {
197 emailScroller = new GoingsOn.VirtualScroller({
198 container: container,
199 renderItem: (t, i) => GoingsOn.emailsRender.renderEmailItem(t, i),
200 getItems: () => GoingsOn.state.emailThreads,
201 rowHeight: { estimated: 90, measure: true },
202 overscan: 5,
203 onNeedMore: loadMoreEmails,
204 });
205 } else {
206 emailScroller.refresh();
207 }
208 GoingsOn.cache.markLoaded('emails');
209 } catch (err) {
210 container.innerHTML = `<div class="loading loading--error">Failed to load emails. <button class="btn-link" data-act="emails.load">Try again</button></div>`;
211 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load emails'), 'error', {
212 action: { label: 'Retry', fn: load },
213 duration: 8000,
214 });
215 } finally {
216 suppressEmailRender = false;
217 }
218 }
219
220 /**
221 * Render a single email item (for virtual scrolling).
222 * @param {Object} thread - Thread object with mostRecentEmail
223 * @param {number} index - Item index
224 * @returns {string} HTML string
225 */
226 async function markAllRead() {
227 await GoingsOn.ui.apiCall(GoingsOn.api.emails.markAllRead(), {
228 successMessage: 'All emails marked as read!',
229 errorMessage: 'Failed to mark emails as read',
230 closeModal: false,
231 onSuccess: () => {
232 GoingsOn.cache.invalidate('emails');
233 // Surgical: clear unread across the streamed threads in place.
234 const threads = GoingsOn.state.emailThreads || [];
235 GoingsOn.state.set('emailThreads', threads.map(t => ({
236 ...t,
237 mostRecentEmail: { ...t.mostRecentEmail, isRead: true },
238 hasUnread: false,
239 })));
240 },
241 });
242 }
243
244 /**
245 * Open an email in reader mode, loading its full thread if available.
246 * Marks the email as read and shows sender contact info.
247 * @param {string} id - Email ID to open
248 */
249 async function open(id) {
250 try {
251 const email = await GoingsOn.api.emails.get(id);
252 if (!email) return;
253
254 // Mark as read
255 await GoingsOn.api.emails.markRead(id);
256
257 // Check if this email is part of a thread
258 let threadEmails = [email];
259 if (email.threadId) {
260 try {
261 const thread = await GoingsOn.api.emails.listByThread(email.threadId);
262 if (thread && thread.length > 1) {
263 // Backend returns threads sorted by received_at ASC
264 threadEmails = thread;
265 }
266 } catch (e) {
267 console.error('Failed to load thread:', e);
268 }
269 }
270
271 const isThread = threadEmails.length > 1;
272
273 // Build action buttons for the most recent email
274 const latestEmail = threadEmails[threadEmails.length - 1];
275 const archiveBtn = latestEmail.isArchived
276 ? `<button class="btn btn-secondary" data-act="emails.unarchive" data-a1="${escAttr(latestEmail.id)}">Unarchive</button>`
277 : `<button class="btn btn-secondary" data-act="emails.archive" data-a1="${escAttr(latestEmail.id)}">Archive</button>`;
278
279 // Use pre-computed field from backend
280 const isSnoozed = latestEmail.isSnoozed;
281 const snoozeBtn = isSnoozed
282 ? `<button class="btn btn-secondary" data-act="snooze.unsnooze" data-a1="email" data-a2="${escAttr(latestEmail.id)}">Unsnooze</button>`
283 : `<button class="btn btn-secondary" data-act="snooze.openModal" data-a1="email" data-a2="${escAttr(latestEmail.id)}">Snooze</button>`;
284
285 // Look up contact from sender email
286 const parsed = GoingsOn.utils.parseEmailAddress(email.from);
287 let senderContact = null;
288 if (parsed.email) {
289 try {
290 senderContact = await GoingsOn.api.contacts.findByEmail(parsed.email);
291 } catch (e) {
292 console.error('Failed to look up contact:', e);
293 }
294 }
295
296 // Build sender contact card
297 let contactCardHtml = '';
298 if (senderContact) {
299 const initials = (senderContact.displayName || senderContact.display_name || '?')
300 .split(/\s+/).map(w => w[0]).join('').substring(0, 2).toUpperCase();
301 const company = senderContact.company ? esc(senderContact.company) : '';
302 contactCardHtml = `
303 <div class="email-sender-contact row-flex row-flex-2">
304 <div class="avatar avatar--sm">${initials}</div>
305 <div class="email-sender-info">
306 <span class="email-sender-name">${esc(senderContact.displayName || senderContact.display_name)}</span>
307 ${company ? `<span class="email-sender-company">${company}</span>` : ''}
308 </div>
309 <button class="btn btn-sm btn-secondary" data-act="ui.closeModalThen" data-a1="contacts.open" data-a2="${escAttr(senderContact.id)}">View Contact</button>
310 </div>
311 `;
312 } else if (parsed.email) {
313 contactCardHtml = `
314 <div class="email-sender-contact row-flex row-flex-2">
315 <div class="avatar avatar--sm avatar--unknown">?</div>
316 <div class="email-sender-info">
317 <span class="email-sender-name">${esc(parsed.name || parsed.email)}</span>
318 </div>
319 <button class="btn btn-sm btn-secondary" data-act="emails.createContactFromSender" data-a1="${escAttr(id)}">+ Save Contact</button>
320 </div>
321 `;
322 }
323
324 // Build attachment panel from all emails in thread
325 const allAttachments = threadEmails.flatMap(e =>
326 (e.attachments || []).map(a => ({ ...a, emailFrom: e.from }))
327 );
328 let attachmentHtml = '';
329 if (allAttachments.length > 0) {
330 const attachmentItems = allAttachments.map(a => {
331 const icon = GoingsOn.attachments.getIcon(a.mimeType);
332 return `
333 <div class="email-attachment-row">
334 <span>${icon}</span>
335 <span class="attachment-filename email-attachment-name"
336 title="${escAttr(a.filename)}">${esc(a.filename)}</span>
337 <span class="email-attachment-size">${esc(a.sizeFormatted)}</span>
338 <button class="btn btn-sm btn-secondary" data-act="emails.openBlob" data-a1="${escAttr(a.blobHash)}" data-a2="${escAttr(a.filename)}" title="Open">Open</button>
339 <button class="btn btn-sm btn-secondary" data-act="emails.saveBlob" data-a1="${escAttr(a.blobHash)}" data-a2="${escAttr(a.filename)}" title="Save">Save</button>
340 </div>
341 `;
342 }).join('');
343
344 attachmentHtml = `
345 <div class="email-attachments-block">
346 <div class="email-attachments-heading">Attachments (${allAttachments.length})</div>
347 ${attachmentItems}
348 </div>
349 `;
350 }
351
352 // Render thread in forum style (oldest first)
353 const threadContent = threadEmails.map((e, index) => {
354 const isLatest = index === threadEmails.length - 1;
355 const dateStr = new Date(e.receivedAt).toLocaleString();
356 const directionIcon = e.isOutgoing ? '&#x2197;' : '&#x2199;'; // arrows for direction
357 const formattedBody = GoingsOn.utils.formatEmailBody(e.body);
358
359 // Bodies over 100KB are truncated at sync; offer to load the rest.
360 const truncatedNotice = e.bodyTruncated
361 ? `<div class="email-body-truncated" id="email-trunc-${escAttr(e.id)}">
362 <span>Message truncated.</span>
363 <button class="btn btn-sm btn-secondary" data-act="emails.loadFullBody" data-a1="${escAttr(e.id)}">Load full message</button>
364 </div>`
365 : '';
366
367 return `
368 <div class="thread-message ${isLatest ? 'thread-message-latest' : ''}">
369 <div class="thread-message-header">
370 <span>${directionIcon} <span class="thread-message-from">${esc(e.from)}</span></span>
371 <span>${dateStr}</span>
372 </div>
373 <div class="email-reader-body" id="email-body-${escAttr(e.id)}">${formattedBody}</div>
374 ${truncatedNotice}
375 </div>
376 `;
377 }).join('');
378
379 const subjectLine = isThread
380 ? `${esc(email.subject)} <span class="email-thread-count">(${threadEmails.length} messages)</span>`
381 : esc(email.subject);
382
383 const content = `
384 <div class="email-reader-container">
385 <div class="email-reader-header">
386 <div class="email-subject-line">${subjectLine}</div>
387 <div class="email-meta-line">
388 From: ${esc(email.from)}
389 ${email.isArchived ? ' · <em>Archived</em>' : ''}
390 ${email.sourceFolder ? ` · ${esc(email.sourceFolder)}` : ''}
391 ${(email.labels || []).length > 0 ? ' · ' + email.labels.map(l => `<span class="badge badge--xs badge--filled" data-color="blue">${esc(l)}</span>`).join(' ') : ''}
392 ${isSnoozed ? ` · <span class="email-snoozed-tag"><em>Snoozed until ${esc(latestEmail.snoozedUntilFormatted || '')}</em></span>` : ''}
393 </div>
394 ${contactCardHtml}
395 </div>
396 ${attachmentHtml}
397 <div class="email-reader-thread">
398 ${threadContent}
399 </div>
400 <div class="form-actions email-actions-bar">
401 <button class="btn btn-primary" data-act="emails.reply" data-a1="${escAttr(latestEmail.id)}">Reply</button>
402 <button class="btn btn-secondary" data-act="emails.replyAll" data-a1="${escAttr(latestEmail.id)}">Reply All</button>
403 <button class="btn btn-secondary" data-act="emails.forward" data-a1="${escAttr(latestEmail.id)}">Forward</button>
404 <button class="btn btn-secondary text-accent-red" data-act="emails.delete" data-a1="${escAttr(latestEmail.id)}">Delete</button>
405 ${archiveBtn}
406 ${snoozeBtn}
407 <button class="btn btn-secondary" data-act="emails.createTaskFromEmail" data-a1="${escAttr(latestEmail.id)}">Create Task</button>
408 <div class="dropdown" style="position: relative;">
409 <button class="btn btn-secondary" data-act="ui.toggleMenu" data-a1="@el">
410 Actions ▾
411 </button>
412 <div class="dropdown-menu">
413 <button class="dropdown-item" data-act="ui.menuAction" data-a1="@el" data-a2="emails.createTaskFromEmail" data-a3="${escAttr(latestEmail.id)}">
414 Convert to Task
415 </button>
416 <button class="dropdown-item" data-act="ui.menuAction" data-a1="@el" data-a2="emails.createEventFromEmail" data-a3="${escAttr(latestEmail.id)}">
417 Convert to Event
418 </button>
419 <button class="dropdown-item" data-act="ui.menuAction" data-a1="@el" data-a2="emails.editLabels" data-a3="${escAttr(latestEmail.id)}" data-a4="${escAttr(JSON.stringify(latestEmail.labels || []))}">
420 Edit Labels
421 </button>
422 <button class="dropdown-item" data-act="ui.menuAction" data-a1="@el" data-a2="emails.moveToFolder" data-a3="${escAttr(latestEmail.id)}">
423 Move to Folder
424 </button>
425 </div>
426 </div>
427 <div class="flex-1"></div>
428 <button class="btn btn-secondary" data-act="emails.openInBrowser" data-a1="${escAttr(latestEmail.id)}" title="Open in browser">Open in Browser</button>
429 </div>
430 </div>
431 `;
432 GoingsOn.ui.openModal(isThread ? 'Thread' : 'Email', content, { large: true });
433 // Surgically reflect the read state instead of refetching the whole
434 // window (ultra-fuzz Run #28 S4) — this is the single most frequent
435 // action in the app. threadEmails is the thread we just loaded.
436 GoingsOn.cache.invalidate('emails');
437 _markThreadReadForEmail(id, threadEmails);
438 } catch (err) {
439 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load email'), 'error');
440 }
441 }
442
443 /**
444 * Delete an email with confirmation dialog.
445 * @param {string} id - Email ID to delete
446 */
447 async function deleteEmail(id) {
448 if (!await GoingsOn.ui.confirmDelete('email')) return;
449
450 await GoingsOn.ui.apiCall(GoingsOn.api.emails.delete(id), {
451 successMessage: 'Email deleted!',
452 errorMessage: 'Failed to delete email',
453 onSuccess: () => {
454 GoingsOn.cache.invalidate('emails');
455 _removeThread(id); // surgical: the deleted email leaves the list
456 },
457 });
458 }
459
460 /**
461 * Archive an email (also moves on IMAP server if available).
462 * @param {string} id - Email ID to archive
463 */
464 async function archive(id) {
465 await GoingsOn.ui.apiCall(GoingsOn.api.emails.archive(id), {
466 successMessage: 'Email archived!',
467 errorMessage: 'Failed to archive email',
468 onSuccess: () => {
469 GoingsOn.cache.invalidate('emails');
470 _removeThread(id); // surgical: archived email leaves the inbox view
471 },
472 });
473 }
474
475 /**
476 * Unarchive an email.
477 * @param {string} id - Email ID to unarchive
478 */
479 async function unarchive(id) {
480 await GoingsOn.ui.apiCall(GoingsOn.api.emails.unarchive(id), {
481 successMessage: 'Email unarchived!',
482 errorMessage: 'Failed to unarchive email',
483 onSuccess: () => {
484 GoingsOn.cache.invalidate('emails');
485 // Surgical: drop it from the archived-folder view. In the inbox view
486 // it isn't present, so this is a no-op there.
487 _removeThread(id);
488 },
489 });
490 }
491
492 /**
493 * Mark an email as read.
494 * @param {string} id - Email ID
495 */
496 async function markRead(id) {
497 await GoingsOn.ui.apiCall(GoingsOn.api.emails.markRead(id), {
498 errorMessage: 'Failed to mark email as read',
499 closeModal: false,
500 onSuccess: () => {
501 GoingsOn.cache.invalidate('emails');
502 _setThreadRead(id, true);
503 },
504 });
505 }
506
507 /**
508 * Mark an email as unread.
509 * @param {string} id - Email ID
510 */
511 async function markUnread(id) {
512 await GoingsOn.ui.apiCall(GoingsOn.api.emails.markUnread(id), {
513 errorMessage: 'Failed to mark email as unread',
514 closeModal: false,
515 onSuccess: () => {
516 GoingsOn.cache.invalidate('emails');
517 _setThreadRead(id, false);
518 },
519 });
520 }
521
522 /**
523 * Create a new contact from an email's sender address.
524 * Parses the From field, creates the contact, and adds the email address.
525 * @param {string} emailId - Email ID to extract sender from
526 */
527 async function createContactFromSender(emailId) {
528 try {
529 const email = await GoingsOn.api.emails.get(emailId);
530 if (!email) {
531 GoingsOn.ui.showToast('Email not found', 'error');
532 return;
533 }
534
535 const parsed = GoingsOn.utils.parseEmailAddress(email.from);
536 if (!parsed.email) {
537 GoingsOn.ui.showToast('Could not parse email address', 'error');
538 return;
539 }
540
541 // Split parsed name into first/last for display name
542 const displayName = parsed.name || parsed.email.split('@')[0];
543
544 // Create the contact
545 const contact = await GoingsOn.api.contacts.create({
546 displayName: displayName,
547 });
548
549 // Add the email address to the new contact
550 await GoingsOn.api.contacts.addEmail(contact.id, {
551 address: parsed.email,
552 label: 'Work',
553 isPrimary: true,
554 });
555
556 // Refresh contacts cache
557 GoingsOn.cache.invalidate('contacts');
558 const contacts = await GoingsOn.api.contacts.list();
559 GoingsOn.state.set('contacts', contacts);
560
561 GoingsOn.ui.showToast('Contact saved!', 'success');
562
563 // Re-open the email reader to show the updated contact card
564 open(emailId);
565 } catch (err) {
566 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to create contact'), 'error');
567 }
568 }
569
570 /**
571 * Create a task from an email's subject and sender info.
572 * Auto-links the sender's contact if one exists.
573 * @param {string} emailId - Source email ID
574 */
575 async function createTaskFromEmail(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 // Auto-link contact from sender email
584 let contactId = null;
585 const parsed = GoingsOn.utils.parseEmailAddress(email.from);
586 if (parsed.email) {
587 try {
588 const contact = await GoingsOn.api.contacts.findByEmail(parsed.email);
589 if (contact) contactId = contact.id;
590 } catch (_) { /* optional — contact may not exist */ }
591 }
592
593 const taskData = {
594 description: email.subject,
595 projectId: email.projectId || null,
596 priority: 'Medium',
597 due: null,
598 tags: [],
599 recurrence: 'None',
600 sourceEmailId: emailId,
601 contactId: contactId,
602 };
603
604 await GoingsOn.api.tasks.create(taskData);
605 GoingsOn.ui.showToast('Task created from email!', 'success');
606 GoingsOn.ui.closeModal();
607 GoingsOn.cache.invalidate('tasks');
608 GoingsOn.tasks.load();
609 } catch (err) {
610 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to create task'), 'error');
611 }
612 }
613
614 /**
615 * Create a calendar event from an email's subject and body.
616 * Defaults to a 1-hour event starting at the next hour.
617 * @param {string} emailId - Source email ID
618 */
619 async function createEventFromEmail(emailId) {
620 try {
621 const email = await GoingsOn.api.emails.get(emailId);
622 if (!email) {
623 GoingsOn.ui.showToast('Email not found', 'error');
624 return;
625 }
626
627 // Auto-link contact from sender email
628 let contactId = null;
629 const parsed = GoingsOn.utils.parseEmailAddress(email.from);
630 if (parsed.email) {
631 try {
632 const contact = await GoingsOn.api.contacts.findByEmail(parsed.email);
633 if (contact) contactId = contact.id;
634 } catch (_) { /* optional — contact may not exist */ }
635 }
636
637 // Default to 1 hour from now, rounded to next hour
638 const now = new Date();
639 now.setMinutes(0, 0, 0);
640 now.setHours(now.getHours() + 1);
641 const startTime = now.toISOString().slice(0, 16); // Format for datetime-local
642
643 const endTime = new Date(now.getTime() + 60 * 60 * 1000); // 1 hour later
644 const endTimeStr = endTime.toISOString().slice(0, 16);
645
646 const eventData = {
647 title: email.subject,
648 projectId: email.projectId || null,
649 startTime: startTime,
650 endTime: endTimeStr,
651 location: '',
652 description: `From: ${email.from}\n\n${email.body.substring(0, 500)}${email.body.length > 500 ? '...' : ''}`,
653 isAllDay: false,
654 contactId: contactId,
655 };
656
657 await GoingsOn.api.events.create(eventData);
658 GoingsOn.ui.showToast('Event created from email!', 'success');
659 GoingsOn.ui.closeModal();
660 GoingsOn.cache.invalidate('events');
661 GoingsOn.events.load();
662 } catch (err) {
663 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to create event'), 'error');
664 }
665 }
666
667 /**
668 * Open compose window for a reply.
669 * @param {string} emailId - Email to reply to
670 * @param {boolean} replyAll - If true, include all recipients
671 */
672 /**
673 * Open an email attachment blob with the system default app.
674 * @param {string} blobHash - SHA-256 hash of the blob
675 * @param {string} filename - Original filename
676 */
677 async function openBlob(blobHash, filename) {
678 try {
679 await GoingsOn.api.attachments.openEmailBlob(blobHash, filename);
680 } catch (err) {
681 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to open attachment'), 'error');
682 }
683 }
684
685 /**
686 * Save an email attachment blob to a user-chosen location.
687 * @param {string} blobHash - SHA-256 hash of the blob
688 * @param {string} filename - Default filename for save dialog
689 */
690 async function saveBlob(blobHash, filename) {
691 try {
692 const { save } = window.__TAURI__.dialog;
693 const destination = await save({
694 defaultPath: filename,
695 title: 'Save attachment as',
696 });
697 if (!destination) return;
698
699 await GoingsOn.api.attachments.saveEmailBlob(blobHash, destination);
700 GoingsOn.ui.showToast('File saved!', 'success');
701 } catch (err) {
702 if (err && err.toString().includes('cancelled')) return;
703 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to save attachment'), 'error');
704 }
705 }
706
707 /**
708 * Extract bare email address from "Name <email>" format.
709 */
710
711 /**
712 * Render email HTML to a temp file and open in the system browser.
713 * @param {string} emailId - Email ID to open
714 */
715 async function openInBrowser(emailId) {
716 try {
717 await GoingsOn.api.window.openEmailInBrowser(emailId);
718 } catch (err) {
719 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to open email in browser'), 'error');
720 }
721 }
722
723 // Stage 4: the modal's attachment picker / render / remove are owned by
724 // composeForm.bindBehaviors. The local pickModalAttachment /
725 // renderModalAttachments / removeModalAttachment helpers and the
726 // modalAttachedFiles array are gone; access via modalComposeCtrl instead.
727
728 // ============ Folder & Label Filters ============
729
730 let activeFolder = '';
731 let activeLabel = '';
732
733 async function loadFilters() {
734 try {
735 const [folders, labels] = await Promise.all([
736 GoingsOn.api.emails.listFolders(),
737 GoingsOn.api.emails.listLabels(),
738 ]);
739
740 const folderSelect = document.getElementById('email-folder-filter');
741 if (folderSelect) {
742 const current = folderSelect.value;
743 folderSelect.innerHTML = '<option value="">All folders</option>' +
744 folders.map(f => `<option value="${escAttr(f)}" ${f === current ? 'selected' : ''}>${esc(f)}</option>`).join('');
745 }
746
747 const labelSelect = document.getElementById('email-label-filter');
748 if (labelSelect) {
749 const current = labelSelect.value;
750 labelSelect.innerHTML = '<option value="">All labels</option>' +
751 labels.map(l => `<option value="${escAttr(l)}" ${l === current ? 'selected' : ''}>${esc(l)}</option>`).join('');
752 }
753 } catch (_) { /* filters are optional */ }
754 }
755
756 function filterByFolder(folder) {
757 activeFolder = folder;
758 GoingsOn.queryState?.write('folder', folder);
759 clearSelectionIfAny();
760 GoingsOn.cache.invalidate('emails');
761 load();
762 }
763
764 function filterByLabel(label) {
765 activeLabel = label;
766 GoingsOn.queryState?.write('label', label);
767 clearSelectionIfAny();
768 GoingsOn.cache.invalidate('emails');
769 load();
770 }
771
772 /**
773 * Phase 7 Tier 4 — restore folder / label / search from URL on init.
774 * Called once at first load; subsequent filter changes write back.
775 */
776 function restoreFiltersFromUrl() {
777 if (!GoingsOn.queryState) return;
778 const q = GoingsOn.queryState.readMany(['folder', 'label', 'q']);
779 if (q.folder) {
780 activeFolder = q.folder;
781 const sel = document.getElementById('email-folder-filter');
782 if (sel) sel.value = q.folder;
783 }
784 if (q.label) {
785 activeLabel = q.label;
786 const sel = document.getElementById('email-label-filter');
787 if (sel) sel.value = q.label;
788 }
789 if (q.q) {
790 const input = document.getElementById('email-search');
791 if (input) input.value = q.q;
792 }
793 }
794
795 // Charter rule (Phase 2 #1): selection clears on filter change so bulk
796 // actions can't target rows the user can no longer see.
797 function clearSelectionIfAny() {
798 if (selectedEmailIds.size > 0) {
799 emailSelection.clear();
800 GoingsOn.bulk?.updateBar?.();
801 }
802 }
803
804 /**
805 * Open a modal to edit labels on an email.
806 * @param {string} emailId - Email ID
807 * @param {string[]} currentLabels - Current labels
808 */
809 async function editLabels(emailId, currentLabels) {
810 const existing = await GoingsOn.api.emails.listLabels();
811 const content = `
812 <form id="label-form" data-submit="emails._saveLabels" data-a1="${escAttr(emailId)}">
813 <div class="form-group">
814 <label class="form-label">Labels (comma-separated)</label>
815 <input type="text" class="form-input" id="label-input" value="${escAttrVal((currentLabels || []).join(', '))}"
816 placeholder="work, important, follow-up" autofocus>
817 ${existing.length > 0 ? `<div class="label-existing-line">Existing: ${existing.map(l => esc(l)).join(', ')}</div>` : ''}
818 </div>
819 <div class="form-actions">
820 <button type="button" class="btn btn-secondary" data-act="ui.closeModal">Cancel</button>
821 <button type="submit" class="btn btn-primary">Save</button>
822 </div>
823 </form>
824 `;
825 GoingsOn.ui.openModal('Edit Labels', content);
826 }
827
828 async function saveLabels(emailId) {
829 const input = document.getElementById('label-input');
830 const labels = input.value.split(',').map(s => s.trim()).filter(Boolean);
831 try {
832 await GoingsOn.api.emails.setLabels(emailId, labels);
833 GoingsOn.ui.showToast('Labels updated!', 'success');
834 GoingsOn.ui.closeModal();
835 GoingsOn.cache.invalidate('emails');
836 // Surgical: patch the thread's labels in place (ultra-fuzz Run #28 S4).
837 const threads = GoingsOn.state.emailThreads || [];
838 GoingsOn.state.set('emailThreads', threads.map(t =>
839 t.mostRecentEmail.id === emailId
840 ? { ...t, mostRecentEmail: { ...t.mostRecentEmail, labels } }
841 : t
842 ));
843 loadFilters();
844 } catch (err) {
845 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to update labels'), 'error');
846 }
847 }
848
849 /**
850 * Move an email to a different folder.
851 * @param {string} emailId - Email ID
852 */
853 async function moveToFolder(emailId) {
854 try {
855 const folders = await GoingsOn.api.emails.listFolders();
856 // Also try to get IMAP folders from account
857 const content = `
858 <form data-submit="emails._doMoveToFolder" data-a1="${escAttr(emailId)}">
859 <div class="form-group">
860 <label class="form-label">Move to folder</label>
861 <input type="text" class="form-input" id="move-folder-input" placeholder="INBOX, Archive, Sent, ..."
862 list="folder-suggestions" autofocus>
863 <datalist id="folder-suggestions">
864 ${folders.map(f => `<option value="${escAttr(f)}">`).join('')}
865 </datalist>
866 </div>
867 <div class="form-actions">
868 <button type="button" class="btn btn-secondary" data-act="ui.closeModal">Cancel</button>
869 <button type="submit" class="btn btn-primary">Move</button>
870 </div>
871 </form>
872 `;
873 GoingsOn.ui.openModal('Move to Folder', content);
874 } catch (err) {
875 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load folders'), 'error');
876 }
877 }
878
879 async function doMoveToFolder(emailId) {
880 const input = document.getElementById('move-folder-input');
881 const folder = input.value.trim();
882 if (!folder) return;
883 try {
884 await GoingsOn.api.emails.moveToFolder(emailId, folder);
885 GoingsOn.ui.showToast(`Moved to ${folder}`, 'success');
886 GoingsOn.ui.closeModal();
887 GoingsOn.cache.invalidate('emails');
888 // Surgical: the moved email leaves the current folder/inbox view
889 // (ultra-fuzz Run #28 S4).
890 _removeThread(emailId);
891 loadFilters();
892 } catch (err) {
893 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to move email'), 'error');
894 }
895 }
896
897 // ============ Email Search ============
898
899 let searchDebounceTimer = null;
900
901 /**
902 * Search emails using FTS5 backend.
903 * @param {string} query - Search query text
904 */
905 function searchEmails(query) {
906 clearTimeout(searchDebounceTimer);
907 const trimmed = (query || '').trim();
908 clearSelectionIfAny();
909 GoingsOn.queryState?.write('q', trimmed);
910
911 if (!trimmed) {
912 // Clear search — reload normal email list. Drop the search-built
913 // scroller (which has no onNeedMore) so load() recreates it with the
914 // paging hook re-armed (ultra-fuzz Run #28 S4).
915 if (emailScroller) { emailScroller.destroy(); emailScroller = null; }
916 GoingsOn.cache.invalidate('emails');
917 load();
918 return;
919 }
920
921 searchDebounceTimer = setTimeout(async () => {
922 try {
923 const response = await GoingsOn.api.search.query({
924 query: trimmed,
925 type: 'email',
926 limit: 100,
927 });
928
929 const container = document.getElementById('email-list');
930
931 if (response.results.length === 0) {
932 if (emailScroller) { emailScroller.destroy(); emailScroller = null; }
933 container.innerHTML = `<div class="loading search-no-results">No emails matching "${esc(trimmed)}"</div>`;
934 return;
935 }
936
937 // Fetch full email data for each result
938 const emailIds = new Set(response.results.map(r => r.id));
939 const allThreads = GoingsOn.state.emailThreads || [];
940 const matchingThreads = allThreads.filter(t => emailIds.has(t.mostRecentEmail.id));
941
942 // If we have cached threads, filter them; otherwise show search results directly
943 if (matchingThreads.length > 0) {
944 GoingsOn.state.set('emailThreads', matchingThreads);
945 if (emailScroller) {
946 emailScroller.refresh();
947 } else {
948 emailScroller = new GoingsOn.VirtualScroller({
949 container: container,
950 renderItem: (t, i) => GoingsOn.emailsRender.renderEmailItem(t, i),
951 getItems: () => GoingsOn.state.emailThreads,
952 rowHeight: { estimated: 90, measure: true },
953 overscan: 5,
954 });
955 }
956 } else {
957 // Render search results as simple list
958 if (emailScroller) { emailScroller.destroy(); emailScroller = null; }
959 container.innerHTML = response.results.map(r => `
960 <div class="email-item" data-id="${escAttr(r.id)}"
961 data-act="emails.open" data-a1="${escAttr(r.id)}"
962 tabindex="0" role="listitem">
963 <div class="email-content">
964 <div class="email-header">
965 <span class="email-subject">${esc(r.title)}</span>
966 </div>
967 ${r.snippet ? `<div class="email-preview">${esc(r.snippet)}</div>` : ''}
968 </div>
969 </div>
970 `).join('');
971 }
972 } catch (err) {
973 console.error('Email search failed:', err);
974 }
975 }, 250);
976 }
977
978 // ============ Pagination ============
979 // Account management, OAuth, and sync live in email-accounts.js
980
981 function goToPage(direction) {
982 emailPagination.goToPage(direction);
983 load();
984 }
985
986 // ============ Email Selection ============
987
988 function toggleSelection(id, checkbox, event) {
989 emailSelection.toggle(id, checkbox, event);
990 }
991
992 function selectAll() {
993 emailSelection.selectAll();
994 }
995
996 function getSelected() {
997 return emailSelection.getSelected();
998 }
999
1000 function clearSelected() {
1001 emailSelection.clear();
1002 }
1003
1004 /**
1005 * Update the "N emails" count chip. Note: total is at thread granularity,
1006 * shown is also at thread granularity, since the filter bar describes the
1007 * visible list which renders one row per thread.
1008 */
1009 function _updateEmailCount(total, shown) {
1010 const el = document.getElementById('email-count');
1011 if (!el) return;
1012 if (typeof total !== 'number' || total < 0) {
1013 el.textContent = '';
1014 el.classList.remove('filter-count--capped');
1015 return;
1016 }
1017 const noun = total === 1 ? 'thread' : 'threads';
1018 if (shown < total) {
1019 el.textContent = `${shown} of ${total} ${noun} — narrow with filters`;
1020 el.classList.add('filter-count--capped');
1021 } else {
1022 el.textContent = `${total} ${noun}`;
1023 el.classList.remove('filter-count--capped');
1024 }
1025 }
1026
1027 // ============ Populate GoingsOn.emails Namespace ============
1028
1029 /**
1030 * Lazily fetch the full body of an email truncated at sync (JMAP >100KB)
1031 * and swap it into the open reader, removing the truncation notice.
1032 */
1033 async function loadFullBody(id) {
1034 const noticeEl = document.getElementById(`email-trunc-${id}`);
1035 if (noticeEl) noticeEl.textContent = 'Loading full message';
1036 try {
1037 const body = await GoingsOn.api.emails.fetchFullBody(id);
1038 const bodyEl = document.getElementById(`email-body-${id}`);
1039 if (bodyEl) bodyEl.innerHTML = GoingsOn.utils.formatEmailBody(body);
1040 if (noticeEl) noticeEl.remove();
1041 } catch (err) {
1042 if (noticeEl) noticeEl.textContent = '';
1043 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load full message'), 'error');
1044 }
1045 }
1046
1047 GoingsOn.emails = {
1048 load,
1049 markAllRead,
1050 open,
1051 loadFullBody,
1052 delete: deleteEmail,
1053 archive,
1054 unarchive,
1055 markRead,
1056 markUnread,
1057 createTaskFromEmail,
1058 createEventFromEmail,
1059 createContactFromSender,
1060 openInBrowser,
1061 openBlob,
1062 saveBlob,
1063 search: searchEmails,
1064 filterByFolder,
1065 filterByLabel,
1066 editLabels,
1067 _saveLabels: saveLabels,
1068 moveToFolder,
1069 _doMoveToFolder: doMoveToFolder,
1070 // Compose/reply/forward/drafts/send-delay live in emails-compose.js;
1071 // delegate lazily so load order between the two files doesn't matter.
1072 openCompose: (...a) => GoingsOn.emailsCompose.openCompose(...a),
1073 openComposeModal: (...a) => GoingsOn.emailsCompose.openComposeModal(...a),
1074 reply: (id) => GoingsOn.emailsCompose.openReply(id, false),
1075 replyAll: (id) => GoingsOn.emailsCompose.openReply(id, true),
1076 forward: (...a) => GoingsOn.emailsCompose.openForward(...a),
1077 openDrafts: (...a) => GoingsOn.emailsCompose.openDraftsModal(...a),
1078 openDraft: (...a) => GoingsOn.emailsCompose.openDraft(...a),
1079 sendDraft: (...a) => GoingsOn.emailsCompose.sendDraft(...a),
1080 queueSend: (...a) => GoingsOn.emailsCompose.queueSend(...a),
1081 // Pagination
1082 goToPage,
1083 toggleSelection,
1084 selectAll,
1085 getSelected,
1086 clearSelected,
1087 // Expose managers
1088 selection: emailSelection,
1089 pagination: emailPagination,
1090 // Virtual scrolling (renderEmailItem lives in emails-render.js)
1091 renderEmailItem: (t, i) => GoingsOn.emailsRender.renderEmailItem(t, i),
1092 getScroller: () => emailScroller,
1093 };
1094
1095 })();
1096