Skip to main content

max / goingson

Split emails.js into thread-state, reader, and filter modules emails.js was a 1095-line god-module. Following the tasks.js precedent it is now the thread list (load, paging, scroller, per-email actions) plus the GoingsOn.emails surface the dispatch layer resolves against, with three modules behind it: emails-threads.js surgical list-state mutations, shared by all three emails-reader.js the reader modal, its fragments, attachments emails-filter.js folder/label state, dropdowns, URL round-trip, modals No behavior change. The reader's four HTML fragments are separate functions rather than one 190-line template, and the thread mutations gained a test suite covering the multi-message unread case.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-25 17:41 UTC
Signed with PGP, not checked
Commit: bfa70e93a7e8aba0293d9b6084e3fb2c105b609b
Parent: aab3e4a
7 files changed, +792 insertions, -303 deletions
@@ -695,6 +695,9 @@
695 695 <script src="js/events.js"></script>
696 696 <script src="js/events-calendar.js"></script>
697 697 <script src="js/emails-render.js"></script>
698 + <script src="js/emails-threads.js"></script>
699 + <script src="js/emails-reader.js"></script>
700 + <script src="js/emails-filter.js"></script>
698 701 <script src="js/emails-compose.js"></script>
699 702 <script src="js/emails.js"></script>
700 703 <script src="js/email-accounts.js"></script>
@@ -186,6 +186,8 @@
186 186 buildReplyPrefill: (id, replyAll) => invoke('build_reply_prefill', { id, replyAll }), // Rust builds reply recipients/subject/quote
187 187 buildForwardPrefill: (id) => invoke('build_forward_prefill', { id }), // Rust builds Fwd: subject + forwarded body
188 188 fetchFullBody: (id) => invoke('fetch_email_full_body', { id }), // Lazy-load a body truncated at sync (JMAP >100KB)
189 + createTaskFrom: (id) => invoke('create_task_from_email', { id }), // Rust derives + creates; resolves the sender contact server-side
190 + createEventFrom: (id) => invoke('create_event_from_email', { id }), // Same, with the next-whole-hour default span
189 191 create: (input) => invoke('create_email', { input }),
190 192 send: (input) => invoke('send_email', { input }), // SMTP send + save copy to local DB
191 193 delete: (id) => invoke('delete_email', { id }),
@@ -1,6 +1,13 @@
1 1 /**
2 2 * GoingsOn - Emails Module
3 - * Email list, compose, threading, actions (archive/delete/mark).
3 + * The thread list: loading, paging, the virtual scroller, and the per-email
4 + * actions that mutate it. `GoingsOn.emails` is the public surface the dispatch
5 + * layer resolves against, so it re-exports the split-out modules:
6 + * emails-threads.js list-state mutations
7 + * emails-reader.js the reader modal, attachments, open-in-browser
8 + * emails-filter.js folder/label filters and their modals
9 + * emails-render.js the list row
10 + * emails-compose.js compose, reply, forward, drafts
4 11 * Account management and OAuth live in email-accounts.js.
5 12 */
6 13
@@ -10,8 +17,8 @@
10 17 'use strict';
11 18 const esc = GoingsOn.utils.escapeHtml;
12 19 const escAttr = GoingsOn.utils.escapeAttrValue;
13 - const escArg = GoingsOn.utils.escapeHandlerArg;
14 - const escAttrVal = GoingsOn.utils.escapeAttrValue;
20 + const threads = GoingsOn.emailsThreads;
21 + const filters = GoingsOn.emailsFilter;
15 22
16 23 // Email Selection & Pagination
17 24
@@ -28,59 +35,17 @@
28 35 // Email threads stored in centralized state for virtual scrolling
29 36 GoingsOn.state.set('emailThreads', []);
30 37
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.
38 + // CHRONIC-E structural fix (ultra-fuzz Run #28 S3/S4). One subscriber, so a
39 + // surgical emailsThreads mutation re-renders the list in place instead of
40 + // refetching the whole window. `suppressEmailRender` gates it during the
41 + // full rebuild in load()/searchEmails so set() there doesn't refresh a
42 + // scroller that is about to be (re)created on the next line.
37 43 let suppressEmailRender = false;
38 44 GoingsOn.state.subscribe('emailThreads', () => {
39 45 if (suppressEmailRender) return;
40 46 if (emailScroller) emailScroller.refresh();
41 47 });
42 48
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 49 // Incremental pagination. The thread list streams in pages via the scroller's
85 50 // onNeedMore hook instead of capping at a fixed 500 rows, so a large mailbox's
86 51 // tail is reachable (ultra-fuzz Run #28 S4). baseFilters holds the current
@@ -88,6 +53,25 @@
88 53 const EMAIL_PAGE_SIZE = 200;
89 54 const emailPaging = { loadedCount: 0, total: 0, baseFilters: null };
90 55
56 + /** Scroller config shared by the list and search paths. */
57 + function scrollerConfig(container, extra) {
58 + return {
59 + container: container,
60 + renderItem: (t, i) => GoingsOn.emailsRender.renderEmailItem(t, i),
61 + getItems: () => GoingsOn.state.emailThreads,
62 + rowHeight: { estimated: 90, measure: true },
63 + overscan: 5,
64 + ...extra,
65 + };
66 + }
67 +
68 + function destroyScroller() {
69 + if (emailScroller) {
70 + emailScroller.destroy();
71 + emailScroller = null;
72 + }
73 + }
74 +
91 75 /**
92 76 * Fetch and append the next page of threads. Wired to the scroller's
93 77 * onNeedMore hook. No-ops once every thread is loaded; on error it surfaces a
@@ -106,13 +90,13 @@
106 90 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load more emails'), 'error');
107 91 return;
108 92 }
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;
93 + const next = threads.all().concat(response.threads);
94 + GoingsOn.state.set('emails', next.map(t => t.mostRecentEmail));
95 + GoingsOn.state.set('emailThreads', next);
96 + emailPaging.loadedCount = next.length;
113 97 emailPaging.total = response.total;
114 - emailSelection.setItems(threads.map(t => ({ id: t.mostRecentEmail.id })));
115 - _updateEmailCount(emailPaging.total, emailPaging.loadedCount);
98 + emailSelection.setItems(next.map(t => ({ id: t.mostRecentEmail.id })));
99 + threads.updateCount(emailPaging.total, emailPaging.loadedCount);
116 100 // Re-arms onNeedMore so the next page can load on further scroll.
117 101 if (emailScroller) emailScroller.refresh();
118 102 }
@@ -128,8 +112,7 @@
128 112 // Phase 7 Tier 4, pull active folder/label/search from URL on first
129 113 // load (e.g. after reload or deep-link). Subsequent filter changes
130 114 // are already URL-mirrored by the respective handlers.
131 - restoreFiltersFromUrl();
132 - const initialSearch = GoingsOn.queryState?.read('q');
115 + const initialSearch = filters.restoreFromUrl();
133 116 if (initialSearch) {
134 117 searchEmails(initialSearch);
135 118 return;
@@ -140,11 +123,7 @@
140 123 suppressEmailRender = true;
141 124 // Reset paging for the current folder/label and fetch the first page; the
142 125 // 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 - };
126 + const baseFilters = { includeArchived: false, ...filters.current() };
148 127 emailPaging.baseFilters = baseFilters;
149 128 emailPaging.loadedCount = 0;
150 129 emailPaging.total = 0;
@@ -157,7 +136,7 @@
157 136 });
158 137
159 138 // Refresh filter dropdowns
160 - loadFilters();
139 + filters.loadFilters();
161 140
162 141 // Update cache with most recent emails
163 142 GoingsOn.state.set('emails', response.threads.map(t => t.mostRecentEmail));
@@ -167,7 +146,7 @@
167 146
168 147 // Phase 7 Tier 2 #9, surface the count; reads "X of N" while later
169 148 // pages stream in on scroll.
170 - _updateEmailCount(response.total, response.threads.length);
149 + threads.updateCount(response.total, response.threads.length);
171 150
172 151 if (response.total === 0) {
173 152 const hasAccounts = GoingsOn.getEmailAccountsCache().length > 0;
@@ -177,11 +156,7 @@
177 156 // Hide pagination
178 157 const paginationEl = document.getElementById('email-pagination');
179 158 if (paginationEl) paginationEl.classList.add('hidden');
180 - // Destroy scroller
181 - if (emailScroller) {
182 - emailScroller.destroy();
183 - emailScroller = null;
184 - }
159 + destroyScroller();
185 160 return;
186 161 }
187 162
@@ -193,17 +168,12 @@
193 168 if (paginationEl) paginationEl.classList.add('hidden');
194 169
195 170 // 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 {
171 + if (emailScroller) {
206 172 emailScroller.refresh();
173 + } else {
174 + emailScroller = new GoingsOn.VirtualScroller(
175 + scrollerConfig(container, { onNeedMore: loadMoreEmails })
176 + );
207 177 }
208 178 GoingsOn.cache.markLoaded('emails');
209 179 } catch (err) {
@@ -217,12 +187,6 @@
217 187 }
218 188 }
219 189
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 190 async function markAllRead() {
227 191 await GoingsOn.ui.apiCall(GoingsOn.api.emails.markAllRead(), {
228 192 successMessage: 'All emails marked as read!',
@@ -231,214 +195,12 @@
231 195 onSuccess: () => {
232 196 GoingsOn.cache.invalidate('emails');
233 197 // 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 - })));
198 + threads.markAllRead();
240 199 },
241 200 });
242 201 }
243 202
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 - }
203 + // Single-Email Actions
442 204
443 205 /**
444 206 * Delete an email with confirmation dialog.
@@ -452,7 +214,7 @@
452 214 errorMessage: 'Failed to delete email',
453 215 onSuccess: () => {
454 216 GoingsOn.cache.invalidate('emails');
455 - _removeThread(id); // surgical: the deleted email leaves the list
217 + threads.remove(id); // surgical: the deleted email leaves the list
456 218 },
457 219 });
458 220 }
@@ -467,7 +229,7 @@
467 229 errorMessage: 'Failed to archive email',
468 230 onSuccess: () => {
469 231 GoingsOn.cache.invalidate('emails');
470 - _removeThread(id); // surgical: archived email leaves the inbox view
232 + threads.remove(id); // surgical: archived email leaves the inbox view
471 233 },
472 234 });
473 235 }
@@ -484,7 +246,7 @@
484 246 GoingsOn.cache.invalidate('emails');
485 247 // Surgical: drop it from the archived-folder view. In the inbox view
486 248 // it isn't present, so this is a no-op there.
487 - _removeThread(id);
249 + threads.remove(id);
488 250 },
489 251 });
490 252 }
@@ -499,7 +261,7 @@
499 261 closeModal: false,
500 262 onSuccess: () => {
501 263 GoingsOn.cache.invalidate('emails');
502 - _setThreadRead(id, true);
264 + threads.setRead(id, true);
503 265 },
504 266 });
505 267 }
@@ -514,14 +276,18 @@
514 276 closeModal: false,
515 277 onSuccess: () => {
516 278 GoingsOn.cache.invalidate('emails');
517 - _setThreadRead(id, false);
279 + threads.setRead(id, false);
518 280 },
519 281 });
520 282 }
521 283
284 + // Conversions
285 + // The derivation rules (subject to title, default event span, body excerpt,
286 + // sender-contact linking) live in goingson_core::email_convert and run in
287 + // one command, so these are just the UI half.
288 +
522 289 /**
523 290 * Create a new contact from an email's sender address.
524 - * Parses the From field, creates the contact, and adds the email address.
525 291 * @param {string} emailId - Email ID to extract sender from
526 292 */
527 293 async function createContactFromSender(emailId) {
@@ -541,12 +307,7 @@
541 307 // Split parsed name into first/last for display name
542 308 const displayName = parsed.name || parsed.email.split('@')[0];
543 309
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
310 + const contact = await GoingsOn.api.contacts.create({ displayName: displayName });
550 311 await GoingsOn.api.contacts.addEmail(contact.id, {
551 312 address: parsed.email,
552 313 label: 'Work',
@@ -561,47 +322,19 @@
561 322 GoingsOn.ui.showToast('Contact saved!', 'success');
562 323
563 324 // Re-open the email reader to show the updated contact card
564 - open(emailId);
Lines truncated
@@ -95,6 +95,7 @@
95 95 window.ResizeObserver = globalThis.ResizeObserver;
96 96
97 97 require('../virtual-scroller'); // GoingsOn.VirtualScroller
98 + require('../emails-threads'); // GoingsOn.emailsThreads (thread-list mutations)
98 99
99 100 // Test: AppStateManager / GoingsOn.state
100 101
@@ -718,6 +719,84 @@
718 719 });
719 720 });
720 721
722 + // Test: email thread-list mutations (CHRONIC-E surgical updates)
723 +
724 + describe('GoingsOn.emailsThreads', () => {
725 + const threads = () => GoingsOn.emailsThreads;
726 +
727 + // Two threads; the second carries a second unread message so the
728 + // "other message still unread" branch is reachable.
729 + function seed() {
730 + GoingsOn.state.set('emailThreads', [
731 + { mostRecentEmail: { id: 'a', isRead: false, labels: [] }, hasUnread: true },
732 + { mostRecentEmail: { id: 'b', isRead: false, labels: ['work'] }, hasUnread: true },
733 + ]);
734 + }
735 +
736 + test('remove drops only the matching thread', () => {
737 + seed();
738 + threads().remove('a');
739 + const ids = GoingsOn.state.emailThreads.map(t => t.mostRecentEmail.id);
740 + assertDeepEqual(ids, ['b']);
741 + });
742 +
743 + test('remove of an unknown id leaves the list intact', () => {
744 + seed();
745 + threads().remove('nope');
746 + assertEqual(GoingsOn.state.emailThreads.length, 2);
747 + });
748 +
749 + test('setRead flips isRead and hasUnread together', () => {
750 + seed();
751 + threads().setRead('a', true);
752 + const a = GoingsOn.state.emailThreads.find(t => t.mostRecentEmail.id === 'a');
753 + const b = GoingsOn.state.emailThreads.find(t => t.mostRecentEmail.id === 'b');
754 + assertEqual(a.mostRecentEmail.isRead, true);
755 + assertEqual(a.hasUnread, false);
756 + assertEqual(b.hasUnread, true, 'the other thread is untouched');
757 + });
758 +
759 + test('setLabels replaces labels on the matching thread only', () => {
760 + seed();
761 + threads().setLabels('b', ['personal', 'urgent']);
762 + const b = GoingsOn.state.emailThreads.find(t => t.mostRecentEmail.id === 'b');
763 + assertDeepEqual(b.mostRecentEmail.labels, ['personal', 'urgent']);
764 + });
765 +
766 + test('markReadForEmail keeps hasUnread when another message is still unread', () => {
767 + seed();
768 + threads().markReadForEmail('b', [
769 + { id: 'b', isRead: true },
770 + { id: 'b-older', isRead: false },
771 + ]);
772 + const b = GoingsOn.state.emailThreads.find(t => t.mostRecentEmail.id === 'b');
773 + assertEqual(b.mostRecentEmail.isRead, true, 'the opened message is read');
774 + assertEqual(b.hasUnread, true, 'the thread still has an unread message');
775 + });
776 +
777 + test('markReadForEmail clears hasUnread when nothing else is unread', () => {
778 + seed();
779 + threads().markReadForEmail('b', [
780 + { id: 'b', isRead: true },
781 + { id: 'b-older', isRead: true },
782 + ]);
783 + const b = GoingsOn.state.emailThreads.find(t => t.mostRecentEmail.id === 'b');
784 + assertEqual(b.hasUnread, false);
785 + });
786 +
787 + test('markAllRead clears unread across every thread', () => {
788 + seed();
789 + threads().markAllRead();
790 + assert(GoingsOn.state.emailThreads.every(t => t.mostRecentEmail.isRead && !t.hasUnread));
791 + });
792 +
793 + test('patch reports whether it matched anything', () => {
794 + seed();
795 + assertEqual(threads().patch('a', t => t), true);
796 + assertEqual(threads().patch('missing', t => t), false);
797 + });
798 + });
799 +
721 800 // Report
722 801
723 802 const success = report();
@@ -1,0 +1,197 @@
1 + /**
2 + * GoingsOn - Email Filter Module
3 + * Folder/label filter state, its dropdowns, URL round-tripping, and the label
4 + * and move-to-folder modals. Loaded before emails.js, populates
5 + * GoingsOn.emailsFilter.
6 + *
7 + * This module owns the filter state but not the list: emails.js reads
8 + * current() when it builds a request and calls setFolder/setLabel from the
9 + * filter handlers, so the reload path stays in one place.
10 + */
11 +
12 + (function() {
13 + 'use strict';
14 + const esc = GoingsOn.utils.escapeHtml;
15 + const escAttr = GoingsOn.utils.escapeAttrValue;
16 +
17 + // Filter State
18 +
19 + let activeFolder = '';
20 + let activeLabel = '';
21 +
22 + /** Current folder/label as the list request wants them (null when unset). */
23 + function current() {
24 + return {
25 + folder: activeFolder || null,
26 + label: activeLabel || null,
27 + };
28 + }
29 +
30 + function setFolder(folder) {
31 + activeFolder = folder;
32 + GoingsOn.queryState?.write('folder', folder);
33 + }
34 +
35 + function setLabel(label) {
36 + activeLabel = label;
37 + GoingsOn.queryState?.write('label', label);
38 + }
39 +
40 + // Filter Dropdowns
41 +
42 + /**
43 + * Repopulate the folder and label dropdowns from the server's current sets,
44 + * preserving whatever is selected. Filters are optional; a failure is silent.
45 + */
46 + async function loadFilters() {
47 + try {
48 + const [folders, labels] = await Promise.all([
49 + GoingsOn.api.emails.listFolders(),
50 + GoingsOn.api.emails.listLabels(),
51 + ]);
52 +
53 + const folderSelect = document.getElementById('email-folder-filter');
54 + if (folderSelect) {
55 + const currentValue = folderSelect.value;
56 + folderSelect.innerHTML = '<option value="">All folders</option>' +
57 + folders.map(f => `<option value="${escAttr(f)}" ${f === currentValue ? 'selected' : ''}>${esc(f)}</option>`).join('');
58 + }
59 +
60 + const labelSelect = document.getElementById('email-label-filter');
61 + if (labelSelect) {
62 + const currentValue = labelSelect.value;
63 + labelSelect.innerHTML = '<option value="">All labels</option>' +
64 + labels.map(l => `<option value="${escAttr(l)}" ${l === currentValue ? 'selected' : ''}>${esc(l)}</option>`).join('');
65 + }
66 + } catch (_) { /* filters are optional */ }
67 + }
68 +
69 + /**
70 + * Phase 7 Tier 4, restore folder / label / search from URL on init.
71 + * Called once at first load; subsequent filter changes write back.
72 + * @returns {string} The restored search query, empty when there is none
73 + */
74 + function restoreFromUrl() {
75 + if (!GoingsOn.queryState) return '';
76 + const q = GoingsOn.queryState.readMany(['folder', 'label', 'q']);
77 + if (q.folder) {
78 + activeFolder = q.folder;
79 + const sel = document.getElementById('email-folder-filter');
80 + if (sel) sel.value = q.folder;
81 + }
82 + if (q.label) {
83 + activeLabel = q.label;
84 + const sel = document.getElementById('email-label-filter');
85 + if (sel) sel.value = q.label;
86 + }
87 + if (q.q) {
88 + const input = document.getElementById('email-search');
89 + if (input) input.value = q.q;
90 + }
91 + return q.q || '';
92 + }
93 +
94 + // Labels
95 +
96 + /**
97 + * Open a modal to edit labels on an email.
98 + * @param {string} emailId - Email ID
99 + * @param {string[]} currentLabels - Current labels
100 + */
101 + async function editLabels(emailId, currentLabels) {
102 + const existing = await GoingsOn.api.emails.listLabels();
103 + const content = `
104 + <form id="label-form" data-submit="emails._saveLabels" data-a1="${escAttr(emailId)}">
105 + <div class="form-group">
106 + <label class="form-label">Labels (comma-separated)</label>
107 + <input type="text" class="form-input" id="label-input" value="${escAttr((currentLabels || []).join(', '))}"
108 + placeholder="work, important, follow-up" autofocus>
109 + ${existing.length > 0 ? `<div class="label-existing-line">Existing: ${existing.map(l => esc(l)).join(', ')}</div>` : ''}
110 + </div>
111 + <div class="form-actions">
112 + <button type="button" class="btn btn-secondary" data-act="ui.closeModal">Cancel</button>
113 + <button type="submit" class="btn btn-primary">Save</button>
114 + </div>
115 + </form>
116 + `;
117 + GoingsOn.ui.openModal('Edit Labels', content);
118 + }
119 +
120 + async function saveLabels(emailId) {
121 + const input = document.getElementById('label-input');
122 + const labels = input.value.split(',').map(s => s.trim()).filter(Boolean);
123 + try {
124 + await GoingsOn.api.emails.setLabels(emailId, labels);
125 + GoingsOn.ui.showToast('Labels updated!', 'success');
126 + GoingsOn.ui.closeModal();
127 + GoingsOn.cache.invalidate('emails');
128 + // Surgical: patch the thread's labels in place (ultra-fuzz Run #28 S4).
129 + GoingsOn.emailsThreads.setLabels(emailId, labels);
130 + loadFilters();
131 + } catch (err) {
132 + GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to update labels'), 'error');
133 + }
134 + }
135 +
136 + // Folders
137 +
138 + /**
139 + * Open a modal to move an email to a different folder.
140 + * @param {string} emailId - Email ID
141 + */
142 + async function moveToFolder(emailId) {
143 + try {
144 + const folders = await GoingsOn.api.emails.listFolders();
145 + const content = `
146 + <form data-submit="emails._doMoveToFolder" data-a1="${escAttr(emailId)}">
147 + <div class="form-group">
148 + <label class="form-label">Move to folder</label>
149 + <input type="text" class="form-input" id="move-folder-input" placeholder="INBOX, Archive, Sent, ..."
150 + list="folder-suggestions" autofocus>
151 + <datalist id="folder-suggestions">
152 + ${folders.map(f => `<option value="${escAttr(f)}">`).join('')}
153 + </datalist>
154 + </div>
155 + <div class="form-actions">
156 + <button type="button" class="btn btn-secondary" data-act="ui.closeModal">Cancel</button>
157 + <button type="submit" class="btn btn-primary">Move</button>
158 + </div>
159 + </form>
160 + `;
161 + GoingsOn.ui.openModal('Move to Folder', content);
162 + } catch (err) {
163 + GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load folders'), 'error');
164 + }
165 + }
166 +
167 + async function doMoveToFolder(emailId) {
168 + const input = document.getElementById('move-folder-input');
169 + const folder = input.value.trim();
170 + if (!folder) return;
171 + try {
172 + await GoingsOn.api.emails.moveToFolder(emailId, folder);
173 + GoingsOn.ui.showToast(`Moved to ${folder}`, 'success');
174 + GoingsOn.ui.closeModal();
175 + GoingsOn.cache.invalidate('emails');
176 + // Surgical: the moved email leaves the current folder/inbox view
177 + // (ultra-fuzz Run #28 S4).
178 + GoingsOn.emailsThreads.remove(emailId);
179 + loadFilters();
180 + } catch (err) {
181 + GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to move email'), 'error');
182 + }
183 + }
184 +
185 + GoingsOn.emailsFilter = {
186 + current,
187 + setFolder,
188 + setLabel,
189 + loadFilters,
190 + restoreFromUrl,
191 + editLabels,
192 + saveLabels,
193 + moveToFolder,
194 + doMoveToFolder,
195 + };
196 +
197 + })();
@@ -1,0 +1,332 @@
1 + /**
2 + * GoingsOn - Email Reader Module
3 + * The reader modal (single message or full thread), its attachment handling,
4 + * and open-in-browser. Loaded before emails.js, populates GoingsOn.emailsReader.
5 + */
6 +
7 + (function() {
8 + 'use strict';
9 + const esc = GoingsOn.utils.escapeHtml;
10 + const escAttr = GoingsOn.utils.escapeAttrValue;
11 +
12 + // Reader Fragments
13 +
14 + /**
15 + * Sender card: the matched contact when one exists, otherwise the parsed
16 + * address with an offer to save it.
17 + * @param {Object|null} contact - Matched contact, or null
18 + * @param {Object} parsed - Output of utils.parseEmailAddress
19 + * @param {string} emailId - Email the card belongs to
20 + * @returns {string} HTML string, empty when there is no address to show
21 + */
22 + function renderSenderCard(contact, parsed, emailId) {
23 + if (contact) {
24 + const name = contact.displayName || contact.display_name;
25 + const initials = (name || '?')
26 + .split(/\s+/).map(w => w[0]).join('').substring(0, 2).toUpperCase();
27 + const company = contact.company ? esc(contact.company) : '';
28 + return `
29 + <div class="email-sender-contact row-flex row-flex-2">
30 + <div class="avatar avatar--sm">${initials}</div>
31 + <div class="email-sender-info">
32 + <span class="email-sender-name">${esc(name)}</span>
33 + ${company ? `<span class="email-sender-company">${company}</span>` : ''}
34 + </div>
35 + <button class="btn btn-sm btn-secondary" data-act="ui.closeModalThen" data-a1="contacts.open" data-a2="${escAttr(contact.id)}">View Contact</button>
36 + </div>
37 + `;
38 + }
39 + if (!parsed.email) return '';
40 + return `
41 + <div class="email-sender-contact row-flex row-flex-2">
42 + <div class="avatar avatar--sm avatar--unknown">?</div>
43 + <div class="email-sender-info">
44 + <span class="email-sender-name">${esc(parsed.name || parsed.email)}</span>
45 + </div>
46 + <button class="btn btn-sm btn-secondary" data-act="emails.createContactFromSender" data-a1="${escAttr(emailId)}">+ Save Contact</button>
47 + </div>
48 + `;
49 + }
50 +
51 + /**
52 + * Attachment panel, pooled across every message in the thread.
53 + * @param {Array} attachments - Attachment records
54 + * @returns {string} HTML string, empty when there are none
55 + */
56 + function renderAttachments(attachments) {
57 + if (attachments.length === 0) return '';
58 + const rows = attachments.map(a => {
59 + const icon = GoingsOn.attachments.getIcon(a.mimeType);
60 + return `
61 + <div class="email-attachment-row">
62 + <span>${icon}</span>
63 + <span class="attachment-filename email-attachment-name"
64 + title="${escAttr(a.filename)}">${esc(a.filename)}</span>
65 + <span class="email-attachment-size">${esc(a.sizeFormatted)}</span>
66 + <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>
67 + <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>
68 + </div>
69 + `;
70 + }).join('');
71 + return `
72 + <div class="email-attachments-block">
73 + <div class="email-attachments-heading">Attachments (${attachments.length})</div>
74 + ${rows}
75 + </div>
76 + `;
77 + }
78 +
79 + /**
80 + * The thread body, forum style, oldest message first.
81 + * @param {Array} threadEmails - Messages ascending by received_at
82 + * @returns {string} HTML string
83 + */
84 + function renderThreadMessages(threadEmails) {
85 + return threadEmails.map((e, index) => {
86 + const isLatest = index === threadEmails.length - 1;
87 + const dateStr = new Date(e.receivedAt).toLocaleString();
88 + const directionIcon = e.isOutgoing ? '&#x2197;' : '&#x2199;'; // arrows for direction
89 + const formattedBody = GoingsOn.utils.formatEmailBody(e.body);
90 +
91 + // Bodies over 100KB are truncated at sync; offer to load the rest.
92 + const truncatedNotice = e.bodyTruncated
93 + ? `<div class="email-body-truncated" id="email-trunc-${escAttr(e.id)}">
94 + <span>Message truncated.</span>
95 + <button class="btn btn-sm btn-secondary" data-act="emails.loadFullBody" data-a1="${escAttr(e.id)}">Load full message</button>
96 + </div>`
97 + : '';
98 +
99 + return `
100 + <div class="thread-message ${isLatest ? 'thread-message-latest' : ''}">
101 + <div class="thread-message-header">
102 + <span>${directionIcon} <span class="thread-message-from">${esc(e.from)}</span></span>
103 + <span>${dateStr}</span>
104 + </div>
105 + <div class="email-reader-body" id="email-body-${escAttr(e.id)}">${formattedBody}</div>
106 + ${truncatedNotice}
107 + </div>
108 + `;
109 + }).join('');
110 + }
111 +
112 + /**
113 + * Action bar for the thread's most recent message.
114 + * @param {Object} latest - The most recent message in the thread
115 + * @returns {string} HTML string
116 + */
117 + function renderActionBar(latest) {
118 + const id = escAttr(latest.id);
119 + const archiveBtn = latest.isArchived
120 + ? `<button class="btn btn-secondary" data-act="emails.unarchive" data-a1="${id}">Unarchive</button>`
121 + : `<button class="btn btn-secondary" data-act="emails.archive" data-a1="${id}">Archive</button>`;
122 + // isSnoozed is pre-computed by the backend.
123 + const snoozeBtn = latest.isSnoozed
124 + ? `<button class="btn btn-secondary" data-act="snooze.unsnooze" data-a1="email" data-a2="${id}">Unsnooze</button>`
125 + : `<button class="btn btn-secondary" data-act="snooze.openModal" data-a1="email" data-a2="${id}">Snooze</button>`;
126 +
127 + return `
128 + <div class="form-actions email-actions-bar">
129 + <button class="btn btn-primary" data-act="emails.reply" data-a1="${id}">Reply</button>
130 + <button class="btn btn-secondary" data-act="emails.replyAll" data-a1="${id}">Reply All</button>
131 + <button class="btn btn-secondary" data-act="emails.forward" data-a1="${id}">Forward</button>
132 + <button class="btn btn-secondary text-accent-red" data-act="emails.delete" data-a1="${id}">Delete</button>
133 + ${archiveBtn}
134 + ${snoozeBtn}
135 + <button class="btn btn-secondary" data-act="emails.createTaskFromEmail" data-a1="${id}">Create Task</button>
136 + <div class="dropdown" style="position: relative;">
137 + <button class="btn btn-secondary" data-act="ui.toggleMenu" data-a1="@el">
138 + Actions â–¾
139 + </button>
140 + <div class="dropdown-menu">
141 + <button class="dropdown-item" data-act="ui.menuAction" data-a1="@el" data-a2="emails.createTaskFromEmail" data-a3="${id}">
142 + Convert to Task
143 + </button>
144 + <button class="dropdown-item" data-act="ui.menuAction" data-a1="@el" data-a2="emails.createEventFromEmail" data-a3="${id}">
145 + Convert to Event
146 + </button>
147 + <button class="dropdown-item" data-act="ui.menuAction" data-a1="@el" data-a2="emails.editLabels" data-a3="${id}" data-a4="${escAttr(JSON.stringify(latest.labels || []))}">
148 + Edit Labels
149 + </button>
150 + <button class="dropdown-item" data-act="ui.menuAction" data-a1="@el" data-a2="emails.moveToFolder" data-a3="${id}">
151 + Move to Folder
152 + </button>
153 + </div>
154 + </div>
155 + <div class="flex-1"></div>
156 + <button class="btn btn-secondary" data-act="emails.openInBrowser" data-a1="${id}" title="Open in browser">Open in Browser</button>
157 + </div>
158 + `;
159 + }
160 +
161 + /**
162 + * Header block: subject, metadata line, sender card.
163 + * @param {Object} email - The email that was opened
164 + * @param {Object} latest - The most recent message in the thread
165 + * @param {number} messageCount - Messages in the thread
166 + * @param {string} senderCardHtml - Output of renderSenderCard
167 + * @returns {string} HTML string
168 + */
169 + function renderHeader(email, latest, messageCount, senderCardHtml) {
170 + const subjectLine = messageCount > 1
171 + ? `${esc(email.subject)} <span class="email-thread-count">(${messageCount} messages)</span>`
172 + : esc(email.subject);
173 + const labels = (email.labels || []).length > 0
174 + ? ' · ' + email.labels.map(l => `<span class="badge badge--xs badge--filled" data-color="blue">${esc(l)}</span>`).join(' ')
175 + : '';
176 + const snoozed = latest.isSnoozed
177 + ? ` · <span class="email-snoozed-tag"><em>Snoozed until ${esc(latest.snoozedUntilFormatted || '')}</em></span>`
178 + : '';
179 +
180 + return `
181 + <div class="email-reader-header">
182 + <div class="email-subject-line">${subjectLine}</div>
183 + <div class="email-meta-line">
184 + From: ${esc(email.from)}
185 + ${email.isArchived ? ' · <em>Archived</em>' : ''}
186 + ${email.sourceFolder ? ` · ${esc(email.sourceFolder)}` : ''}
187 + ${labels}
188 + ${snoozed}
189 + </div>
190 + ${senderCardHtml}
191 + </div>
192 + `;
193 + }
194 +
195 + // Reader
196 +
197 + /**
198 + * Open an email in reader mode, loading its full thread if available.
199 + * Marks the email as read and shows sender contact info.
200 + * @param {string} id - Email ID to open
201 + */
202 + async function open(id) {
203 + try {
204 + const email = await GoingsOn.api.emails.get(id);
205 + if (!email) return;
206 +
207 + await GoingsOn.api.emails.markRead(id);
208 +
209 + // Backend returns threads sorted by received_at ASC.
210 + let threadEmails = [email];
211 + if (email.threadId) {
212 + try {
213 + const thread = await GoingsOn.api.emails.listByThread(email.threadId);
214 + if (thread && thread.length > 1) threadEmails = thread;
215 + } catch (e) {
216 + console.error('Failed to load thread:', e);
217 + }
218 + }
219 + const latest = threadEmails[threadEmails.length - 1];
220 +
221 + const parsed = GoingsOn.utils.parseEmailAddress(email.from);
222 + let senderContact = null;
223 + if (parsed.email) {
224 + try {
225 + senderContact = await GoingsOn.api.contacts.findByEmail(parsed.email);
226 + } catch (e) {
227 + console.error('Failed to look up contact:', e);
228 + }
229 + }
230 +
231 + const attachments = threadEmails.flatMap(e =>
232 + (e.attachments || []).map(a => ({ ...a, emailFrom: e.from }))
233 + );
234 +
235 + const content = `
236 + <div class="email-reader-container">
237 + ${renderHeader(email, latest, threadEmails.length, renderSenderCard(senderContact, parsed, id))}
238 + ${renderAttachments(attachments)}
239 + <div class="email-reader-thread">
240 + ${renderThreadMessages(threadEmails)}
241 + </div>
242 + ${renderActionBar(latest)}
243 + </div>
244 + `;
245 + GoingsOn.ui.openModal(threadEmails.length > 1 ? 'Thread' : 'Email', content, { large: true });
246 + // Surgically reflect the read state instead of refetching the whole
247 + // window (ultra-fuzz Run #28 S4); this is the single most frequent
248 + // action in the app. threadEmails is the thread we just loaded.
249 + GoingsOn.cache.invalidate('emails');
250 + GoingsOn.emailsThreads.markReadForEmail(id, threadEmails);
251 + } catch (err) {
252 + GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load email'), 'error');
253 + }
254 + }
255 +
256 + /**
257 + * Lazily fetch the full body of an email truncated at sync (JMAP >100KB)
258 + * and swap it into the open reader, removing the truncation notice.
259 + * @param {string} id - Email ID
260 + */
261 + async function loadFullBody(id) {
262 + const noticeEl = document.getElementById(`email-trunc-${id}`);
263 + if (noticeEl) noticeEl.textContent = 'Loading full message';
264 + try {
265 + const body = await GoingsOn.api.emails.fetchFullBody(id);
266 + const bodyEl = document.getElementById(`email-body-${id}`);
267 + if (bodyEl) bodyEl.innerHTML = GoingsOn.utils.formatEmailBody(body);
268 + if (noticeEl) noticeEl.remove();
269 + } catch (err) {
270 + if (noticeEl) noticeEl.textContent = '';
271 + GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load full message'), 'error');
272 + }
273 + }
274 +
275 + // Attachments & External Open
276 +
277 + /**
278 + * Open an email attachment blob with the system default app.
279 + * @param {string} blobHash - SHA-256 hash of the blob
280 + * @param {string} filename - Original filename
281 + */
282 + async function openBlob(blobHash, filename) {
283 + try {
284 + await GoingsOn.api.attachments.openEmailBlob(blobHash, filename);
285 + } catch (err) {
286 + GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to open attachment'), 'error');
287 + }
288 + }
289 +
290 + /**
291 + * Save an email attachment blob to a user-chosen location.
292 + * @param {string} blobHash - SHA-256 hash of the blob
293 + * @param {string} filename - Default filename for save dialog
294 + */
295 + async function saveBlob(blobHash, filename) {
296 + try {
297 + const { save } = window.__TAURI__.dialog;
298 + const destination = await save({
299 + defaultPath: filename,
300 + title: 'Save attachment as',
301 + });
302 + if (!destination) return;
303 +
304 + await GoingsOn.api.attachments.saveEmailBlob(blobHash, destination);
305 + GoingsOn.ui.showToast('File saved!', 'success');
306 + } catch (err) {
307 + if (err && err.toString().includes('cancelled')) return;
308 + GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to save attachment'), 'error');
309 + }
310 + }
311 +
312 + /**
313 + * Render email HTML to a temp file and open in the system browser.
314 + * @param {string} emailId - Email ID to open
315 + */
316 + async function openInBrowser(emailId) {
317 + try {
318 + await GoingsOn.api.window.openEmailInBrowser(emailId);
319 + } catch (err) {
320 + GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to open email in browser'), 'error');
321 + }
322 + }
323 +
324 + GoingsOn.emailsReader = {
325 + open,
326 + loadFullBody,
327 + openBlob,
328 + saveBlob,
329 + openInBrowser,
330 + };
331 +
332 + })();
@@ -1,0 +1,116 @@
1 + /**
2 + * GoingsOn - Email Thread List State
3 + * Surgical mutations of the loaded thread list, plus the list count chip.
4 + * Loaded before emails.js, populates GoingsOn.emailsThreads.
5 + */
6 +
7 + (function() {
8 + 'use strict';
9 +
10 + // CHRONIC-E structural fix (ultra-fuzz Run #28 S3/S4). Reading an email, the
11 + // single most frequent action in the app, used to refetch the whole 500-thread
12 + // window. Every mutation here edits state.emailThreads in place and lets
13 + // emails.js's subscriber refresh the scroller, replacing the old
14 + // `reload: load` that refetched on every single-email action.
15 + //
16 + // Threads are matched by their representative (most-recent) email id, which is
17 + // the id the list rows and the reader action bar operate on.
18 +
19 + /** Current thread list, never null. */
20 + function all() {
21 + return GoingsOn.state.emailThreads || [];
22 + }
23 +
24 + /** Drop the thread whose representative email is `emailId` from the list. */
25 + function remove(emailId) {
26 + GoingsOn.state.set('emailThreads', all().filter(t => t.mostRecentEmail.id !== emailId));
27 + }
28 +
29 + /**
30 + * Replace the representative email of one thread via `patch`, leaving the rest
31 + * of the list untouched. `patch` receives the thread and returns the new one.
32 + */
33 + function patch(emailId, patchFn) {
34 + let changed = false;
35 + const next = all().map(t => {
36 + if (t.mostRecentEmail.id !== emailId) return t;
37 + changed = true;
38 + return patchFn(t);
39 + });
40 + if (changed) GoingsOn.state.set('emailThreads', next);
41 + return changed;
42 + }
43 +
44 + /** Toggle a thread's read state. Mirrors the bulk markRead pattern. */
45 + function setRead(emailId, read) {
46 + patch(emailId, t => ({
47 + ...t,
48 + mostRecentEmail: { ...t.mostRecentEmail, isRead: read },
49 + hasUnread: !read,
50 + }));
51 + }
52 +
53 + /** Replace a thread's labels in place after an edit. */
54 + function setLabels(emailId, labels) {
55 + patch(emailId, t => ({ ...t, mostRecentEmail: { ...t.mostRecentEmail, labels } }));
56 + }
57 +
58 + /**
59 + * Mark the opened message read and recompute the thread's unread flag from the
60 + * thread we just loaded. hasUnread is cleared only when no *other* message in
61 + * the thread is still unread, so a multi-message thread with remaining unread
62 + * keeps its dot rather than wrongly showing read (GO todo hazard note).
63 + */
64 + function markReadForEmail(emailId, threadEmails) {
65 + const stillUnread = (threadEmails || []).some(e => e.id !== emailId && !e.isRead);
66 + patch(emailId, t => ({
67 + ...t,
68 + mostRecentEmail: { ...t.mostRecentEmail, isRead: true },
69 + hasUnread: stillUnread,
70 + }));
71 + }
72 +
73 + /** Clear unread across every loaded thread. */
74 + function markAllRead() {
75 + GoingsOn.state.set('emailThreads', all().map(t => ({
76 + ...t,
77 + mostRecentEmail: { ...t.mostRecentEmail, isRead: true },
78 + hasUnread: false,
79 + })));
80 + }
81 +
82 + /**
83 + * Update the "N threads" count chip. Both numbers are at thread granularity,
84 + * since the filter bar describes the visible list, which renders one row per
85 + * thread.
86 + */
87 + function updateCount(total, shown) {
88 + const el = document.getElementById('email-count');
89 + if (!el) return;
90 + if (typeof total !== 'number' || total < 0) {
91 + el.textContent = '';
92 + el.classList.remove('filter-count--capped');
93 + return;
94 + }
95 + const noun = total === 1 ? 'thread' : 'threads';
96 + if (shown < total) {
97 + el.textContent = `${shown} of ${total} ${noun}, narrow with filters`;
98 + el.classList.add('filter-count--capped');
99 + } else {
100 + el.textContent = `${total} ${noun}`;
101 + el.classList.remove('filter-count--capped');
102 + }
103 + }
104 +
105 + GoingsOn.emailsThreads = {
106 + all,
107 + remove,
108 + patch,
109 + setRead,
110 + setLabels,
111 + markReadForEmail,
112 + markAllRead,
113 + updateCount,
114 + };
115 +
116 + })();