Skip to main content

max / goingson

perf(ui): stream task pages and lazy-load truncated email bodies Close the two ultra-fuzz Run #26 Performance items (silent truncation): - Task list no longer caps at 500 rows. VirtualScroller gains an onNeedMore infinite-scroll hook; tasks.js streams 200-row pages via the existing list_tasks_filtered offset/limit. Count chip reads "X of N" until fully loaded. - JMAP bodies over 100KB are no longer silently truncated. Migration 052 adds jmap_id + body_truncated; sync captures BodyValue.isTruncated; a new fetch_email_full_body command (shared all_commands! macro) re-fetches the full body on demand, surfaced via a "Load full message" affordance in the reader. JMAP client construction extracted to a shared build_jmap_client. Gate: cargo test --workspace green, node --check clean on touched JS. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-16 21:59 UTC
Commit: 67894afbf48a2795756b394eba4b4bac7d510901
Parent: 2aba1ec
15 files changed, +307 insertions, -45 deletions
@@ -32,6 +32,10 @@ pub struct FetchedEmail {
32 32 pub imap_uid: Option<i64>,
33 33 pub is_archived: bool,
34 34 pub attachment_meta: Option<String>,
35 + /// Whether the body was truncated at fetch (JMAP >100KB cap).
36 + pub body_truncated: bool,
37 + /// Provider email id (JMAP), used to re-fetch a truncated body. `None` for IMAP.
38 + pub jmap_id: Option<String>,
35 39 }
36 40
37 41 /// Result of processing a batch of fetched emails.
@@ -126,6 +130,8 @@ pub async fn process_fetched_emails(
126 130 email_account_id: Some(account_id),
127 131 is_outgoing: false,
128 132 attachment_meta: email.attachment_meta,
133 + body_truncated: email.body_truncated,
134 + jmap_id: email.jmap_id,
129 135 });
130 136 }
131 137
@@ -173,6 +179,8 @@ mod tests {
173 179 imap_uid: Some(42),
174 180 is_archived: false,
175 181 attachment_meta: None,
182 + body_truncated: false,
183 + jmap_id: None,
176 184 };
177 185 assert_eq!(email.from, "sender@example.com");
178 186 assert_eq!(email.imap_uid, Some(42));
@@ -35,6 +35,13 @@ pub struct Email {
35 35 pub body: String,
36 36 /// Original HTML body for "Open in Browser" feature.
37 37 pub html_body: Option<String>,
38 + /// Whether the body was truncated at sync (JMAP >100KB). When true, the full
39 + /// body can be re-fetched on demand via the provider id.
40 + pub body_truncated: bool,
41 + /// Provider email id for JMAP accounts, used to re-fetch a truncated body
42 + /// (internal — never serialized to the frontend).
43 + #[serde(skip_serializing)]
44 + pub jmap_id: Option<String>,
38 45 /// Whether the email has been read.
39 46 pub is_read: bool,
40 47 /// Whether the email is archived.
@@ -186,6 +193,8 @@ mod tests {
186 193 subject: "Test".into(),
187 194 body: "Hello world".into(),
188 195 html_body: None,
196 + body_truncated: false,
197 + jmap_id: None,
189 198 is_read: false,
190 199 is_archived: false,
191 200 received_at: Utc::now(),
@@ -379,4 +388,8 @@ pub struct NewEmailWithTracking {
379 388 pub imap_uid: Option<i64>,
380 389 pub source_folder: Option<String>,
381 390 pub attachment_meta: Option<String>,
391 + /// Whether the body was truncated at sync (JMAP >100KB cap).
392 + pub body_truncated: bool,
393 + /// Provider email id (JMAP) for re-fetching a truncated body. `None` for IMAP.
394 + pub jmap_id: Option<String>,
382 395 }
@@ -337,6 +337,10 @@ pub trait EmailRepository: Send + Sync {
337 337 /// Retrieves an email by ID.
338 338 async fn get_by_id(&self, id: EmailId, user_id: UserId) -> Result<Option<Email>>;
339 339
340 + /// Replaces an email's body with a full (re-fetched) version and clears the
341 + /// `body_truncated` flag. Used to lazily load a body truncated at sync.
342 + async fn set_full_body(&self, id: EmailId, user_id: UserId, body: &str) -> Result<()>;
343 +
340 344 /// Creates a new email record.
341 345 async fn create(&self, user_id: UserId, email: NewEmail) -> Result<Email>;
342 346
@@ -47,7 +47,7 @@ pub async fn run_migrations(pool: &SqlitePool) -> Result<(), sqlx::migrate::Migr
47 47 sqlx::query("PRAGMA foreign_keys = ON")
48 48 .execute(pool)
49 49 .await
50 - .map_err(|e| sqlx::migrate::MigrateError::Execute(e))?;
50 + .map_err(sqlx::migrate::MigrateError::Execute)?;
51 51
52 52 Ok(())
53 53 }
@@ -24,7 +24,8 @@ const EMAIL_SELECT_COLUMNS: &str = r#"e.id, e.project_id, p.name as project_name
24 24 e.subject, e.body, e.html_body, e.is_read, e.is_archived, e.received_at, e.message_id,
25 25 e.in_reply_to, e.thread_id, e.email_account_id, e.is_outgoing, e.imap_uid, e.source_folder,
26 26 e.attachment_meta, e.labels, e.is_draft, e.cc_address, e.bcc_address, e.draft_account_id,
27 - e.snoozed_until, e.waiting_for_response, e.waiting_since, e.expected_response_date"#;
27 + e.snoozed_until, e.waiting_for_response, e.waiting_since, e.expected_response_date,
28 + e.body_truncated, e.jmap_id"#;
28 29
29 30 #[derive(Debug, Clone, sqlx::FromRow)]
30 31 struct EmailRow {
@@ -56,6 +57,8 @@ struct EmailRow {
56 57 pub waiting_for_response: i32,
57 58 pub waiting_since: Option<String>,
58 59 pub expected_response_date: Option<String>,
60 + pub body_truncated: i32,
61 + pub jmap_id: Option<String>,
59 62 }
60 63
61 64 impl TryFrom<EmailRow> for Email {
@@ -71,6 +74,8 @@ impl TryFrom<EmailRow> for Email {
71 74 subject: row.subject,
72 75 body: row.body,
73 76 html_body: row.html_body,
77 + body_truncated: row.body_truncated != 0,
78 + jmap_id: row.jmap_id,
74 79 is_read: row.is_read != 0,
75 80 is_archived: row.is_archived != 0,
76 81 received_at: parse_datetime(&row.received_at)?,
@@ -353,7 +358,7 @@ impl EmailRepository for SqliteEmailRepository {
353 358 async fn create_with_tracking(&self, user_id: UserId, email: NewEmailWithTracking) -> Result<Email> {
354 359 let id = goingson_core::deterministic_email_id(email.message_id.as_deref());
355 360 let received_at = format_datetime(&email.received_at.unwrap_or_else(Utc::now));
356 - sqlx::query("INSERT INTO emails (id, user_id, project_id, from_address, to_address, subject, body, html_body, is_read, is_archived, received_at, message_id, in_reply_to, thread_id, email_account_id, is_outgoing, imap_uid, source_folder, attachment_meta) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
361 + sqlx::query("INSERT INTO emails (id, user_id, project_id, from_address, to_address, subject, body, html_body, is_read, is_archived, received_at, message_id, in_reply_to, thread_id, email_account_id, is_outgoing, imap_uid, source_folder, attachment_meta, body_truncated, jmap_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
357 362 .bind(id.to_string()).bind(user_id.to_string()).bind(email.project_id.map(|p| p.to_string()))
358 363 .bind(&email.from_address).bind(&email.to_address).bind(&email.subject).bind(&email.body).bind(&email.html_body)
359 364 .bind(if email.is_read { 1 } else { 0 }).bind(if email.is_archived { 1 } else { 0 }).bind(&received_at)
@@ -361,6 +366,7 @@ impl EmailRepository for SqliteEmailRepository {
361 366 .bind(email.email_account_id.map(|a| a.to_string()))
362 367 .bind(if email.is_outgoing { 1 } else { 0 }).bind(email.imap_uid).bind(&email.source_folder)
363 368 .bind(&email.attachment_meta)
369 + .bind(if email.body_truncated { 1 } else { 0 }).bind(&email.jmap_id)
364 370 .execute(&self.pool).await.map_err(CoreError::database)?;
365 371 self.get_by_id(id, user_id).await?.ok_or_else(|| CoreError::internal("Failed to retrieve created email"))
366 372 }
@@ -379,7 +385,7 @@ impl EmailRepository for SqliteEmailRepository {
379 385 for email in emails {
380 386 let id = goingson_core::deterministic_email_id(email.message_id.as_deref());
381 387 let received_at = format_datetime(&email.received_at.unwrap_or_else(Utc::now));
382 - let result = sqlx::query("INSERT OR IGNORE INTO emails (id, user_id, project_id, from_address, to_address, subject, body, html_body, is_read, is_archived, received_at, message_id, in_reply_to, thread_id, email_account_id, is_outgoing, imap_uid, source_folder, attachment_meta) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
388 + let result = sqlx::query("INSERT OR IGNORE INTO emails (id, user_id, project_id, from_address, to_address, subject, body, html_body, is_read, is_archived, received_at, message_id, in_reply_to, thread_id, email_account_id, is_outgoing, imap_uid, source_folder, attachment_meta, body_truncated, jmap_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
383 389 .bind(id.to_string()).bind(&uid).bind(email.project_id.map(|p| p.to_string()))
384 390 .bind(&email.from_address).bind(&email.to_address).bind(&email.subject).bind(&email.body).bind(&email.html_body)
385 391 .bind(if email.is_read { 1 } else { 0 }).bind(if email.is_archived { 1 } else { 0 }).bind(&received_at)
@@ -387,6 +393,7 @@ impl EmailRepository for SqliteEmailRepository {
387 393 .bind(email.email_account_id.map(|a| a.to_string()))
388 394 .bind(if email.is_outgoing { 1 } else { 0 }).bind(email.imap_uid).bind(&email.source_folder)
389 395 .bind(&email.attachment_meta)
396 + .bind(if email.body_truncated { 1 } else { 0 }).bind(&email.jmap_id)
390 397 .execute(&mut *tx).await.map_err(CoreError::database)?;
391 398 if result.rows_affected() > 0 {
392 399 count += 1;
@@ -434,6 +441,14 @@ impl EmailRepository for SqliteEmailRepository {
434 441 }
435 442
436 443 #[tracing::instrument(skip_all)]
444 + async fn set_full_body(&self, id: EmailId, user_id: UserId, body: &str) -> Result<()> {
445 + sqlx::query("UPDATE emails SET body = ?, body_truncated = 0 WHERE id = ? AND user_id = ?")
446 + .bind(body).bind(id.to_string()).bind(user_id.to_string())
447 + .execute(&self.pool).await.map_err(CoreError::database)?;
448 + Ok(())
449 + }
450 +
451 + #[tracing::instrument(skip_all)]
437 452 async fn mark_all_read(&self, user_id: UserId) -> Result<u64> {
438 453 let result = sqlx::query("UPDATE emails SET is_read = 1 WHERE user_id = ? AND is_read = 0").bind(user_id.to_string()).execute(&self.pool).await.map_err(CoreError::database)?;
439 454 Ok(result.rows_affected())
@@ -182,6 +182,8 @@ async fn test_email_attachment_meta_persisted() {
182 182 imap_uid: Some(99),
183 183 source_folder: Some("INBOX".into()),
184 184 attachment_meta: Some(meta_json.into()),
185 + body_truncated: false,
186 + jmap_id: None,
185 187 })
186 188 .await
187 189 .expect("Failed to create email with attachment_meta");
@@ -0,0 +1,9 @@
1 + -- Lazy-load full email body for JMAP accounts.
2 + --
3 + -- JMAP sync fetches bodies with maxBodyValueBytes=100000; larger bodies are
4 + -- truncated. We record the provider email id (jmap_id) so the full body can be
5 + -- re-fetched on demand, and a flag marking which rows were truncated at sync.
6 + -- Both are additive; existing rows default to "not truncated, no jmap id".
7 +
8 + ALTER TABLE emails ADD COLUMN jmap_id TEXT;
9 + ALTER TABLE emails ADD COLUMN body_truncated INTEGER NOT NULL DEFAULT 0;
@@ -121,6 +121,7 @@ const api = {
121 121 listByProject: (projectId) => invoke('list_emails_for_project', { projectId }),
122 122 listUnlinked: () => invoke('list_unlinked_emails'), // Emails not linked to any project
123 123 get: (id) => invoke('get_email', { id }),
124 + fetchFullBody: (id) => invoke('fetch_email_full_body', { id }), // Lazy-load a body truncated at sync (JMAP >100KB)
124 125 create: (input) => invoke('create_email', { input }),
125 126 send: (input) => invoke('send_email', { input }), // SMTP send + save copy to local DB
126 127 delete: (id) => invoke('delete_email', { id }),
@@ -429,13 +429,22 @@
429 429 const directionIcon = e.isOutgoing ? '&#x2197;' : '&#x2199;'; // arrows for direction
430 430 const formattedBody = GoingsOn.utils.formatEmailBody(e.body);
431 431
432 + // Bodies over 100KB are truncated at sync; offer to load the rest.
433 + const truncatedNotice = e.bodyTruncated
434 + ? `<div class="email-body-truncated" id="email-trunc-${escAttr(e.id)}">
435 + <span>Message truncated.</span>
436 + <button class="btn btn-sm btn-secondary" onclick="GoingsOn.emails.loadFullBody('${escAttr(e.id)}')">Load full message</button>
437 + </div>`
438 + : '';
439 +
432 440 return `
433 441 <div class="thread-message ${isLatest ? 'thread-message-latest' : ''}">
434 442 <div class="thread-message-header">
435 443 <span>${directionIcon} <span class="thread-message-from">${esc(e.from)}</span></span>
436 444 <span>${dateStr}</span>
437 445 </div>
438 - <div class="email-reader-body">${formattedBody}</div>
446 + <div class="email-reader-body" id="email-body-${escAttr(e.id)}">${formattedBody}</div>
447 + ${truncatedNotice}
439 448 </div>
440 449 `;
441 450 }).join('');
@@ -1375,12 +1384,31 @@
1375 1384
1376 1385 // ============ Populate GoingsOn.emails Namespace ============
1377 1386
1387 + /**
1388 + * Lazily fetch the full body of an email truncated at sync (JMAP >100KB)
1389 + * and swap it into the open reader, removing the truncation notice.
1390 + */
1391 + async function loadFullBody(id) {
1392 + const noticeEl = document.getElementById(`email-trunc-${id}`);
1393 + if (noticeEl) noticeEl.textContent = 'Loading full message';
1394 + try {
1395 + const body = await GoingsOn.api.emails.fetchFullBody(id);
1396 + const bodyEl = document.getElementById(`email-body-${id}`);
1397 + if (bodyEl) bodyEl.innerHTML = GoingsOn.utils.formatEmailBody(body);
1398 + if (noticeEl) noticeEl.remove();
1399 + } catch (err) {
1400 + if (noticeEl) noticeEl.textContent = '';
1401 + GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load full message'), 'error');
1402 + }
1403 + }
1404 +
1378 1405 GoingsOn.emails = {
1379 1406 load,
1380 1407 openCompose,
1381 1408 openComposeModal,
1382 1409 markAllRead,
1383 1410 open,
1411 + loadFullBody,
1384 1412 delete: deleteEmail,
1385 1413 archive,
1386 1414 unarchive,
@@ -22,6 +22,47 @@
22 22 // Virtual scroller instance
23 23 let taskScroller = null;
24 24
25 + // Incremental pagination state. The task list streams in pages as the user
26 + // scrolls (via the scroller's onNeedMore hook) instead of capping at a fixed
27 + // row count, so a large task set is fully reachable. baseFilters holds the
28 + // current filter/sort so each page request is consistent.
29 + const TASK_PAGE_SIZE = 200;
30 + const taskPaging = { loadedCount: 0, total: 0, baseFilters: null };
31 +
32 + /** Attach the display-only fields the row renderer expects. */
33 + function _decorateTasks(tasks) {
34 + return tasks.map(t => ({ ...t, displayDescription: t.description }));
35 + }
36 +
37 + /**
38 + * Fetch and append the next page of tasks. Wired to the scroller's
39 + * onNeedMore hook. No-ops once every task is loaded (leaving the trigger
40 + * disarmed); on error it surfaces a toast and stops paging rather than
41 + * spinning.
42 + */
43 + async function loadMoreTasks() {
44 + if (taskPaging.loadedCount >= taskPaging.total || !taskPaging.baseFilters) return;
45 + let response;
46 + try {
47 + response = await GoingsOn.api.tasks.listFiltered({
48 + ...taskPaging.baseFilters,
49 + offset: taskPaging.loadedCount,
50 + limit: TASK_PAGE_SIZE,
51 + });
52 + } catch (err) {
53 + GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load more tasks'), 'error');
54 + return;
55 + }
56 + const all = (GoingsOn.state.tasks || []).concat(_decorateTasks(response.tasks));
57 + GoingsOn.state.set('tasks', all);
58 + taskPaging.loadedCount = all.length;
59 + taskPaging.total = response.total;
60 + taskSelection.setItems(all);
61 + _updateTaskCount(taskPaging.total, taskPaging.loadedCount);
62 + // Re-arms the onNeedMore trigger so the next page can load on further scroll.
63 + if (taskScroller) taskScroller.refresh();
64 + }
65 +
25 66 // ============ Delegated references ============
26 67
27 68 const getTaskFormFields = (...args) => GoingsOn.taskForms.getTaskFormFields(...args);
@@ -58,8 +99,9 @@
58 99 * Fetch filtered/sorted tasks from backend and render via virtual scroller.
59 100 */
60 101 /**
61 - * Update the "N tasks" count chip in the filter bar.
62 - * Surfaces the 500-row cap when total > shown.
102 + * Update the "N tasks" count chip in the filter bar. While pages are still
103 + * streaming in (shown < total) it reads "X of N"; once everything is loaded
104 + * it reads the plain total.
63 105 */
64 106 function _updateTaskCount(total, shown) {
65 107 const el = document.getElementById('task-count');
@@ -71,7 +113,7 @@
71 113 }
72 114 const noun = total === 1 ? 'task' : 'tasks';
73 115 if (shown < total) {
74 - el.textContent = `${shown} of ${total} ${noun} — narrow with filters`;
116 + el.textContent = `${shown} of ${total} ${noun}`;
75 117 el.classList.add('filter-count--capped');
76 118 } else {
77 119 el.textContent = `${total} ${noun}`;
@@ -83,12 +125,11 @@
83 125 const container = document.getElementById('task-list-container');
84 126 const uiFilters = GoingsOn.tasksFilter.getFilters();
85 127
86 - // Build filter query for backend - fetch more items for virtual scrolling
128 + // Build the filter/sort query for the backend. Offset/limit are added
129 + // per page — the list streams in via the scroller's onNeedMore hook.
87 130 const backendFilters = {
88 131 showSnoozed: uiFilters.showSnoozed,
89 132 waitingOnly: uiFilters.waitingOnly,
90 - offset: 0,
91 - limit: 500, // Fetch more for virtual scrolling
92 133 };
93 134
94 135 // Status: UI uses lowercase ('pending'), backend expects capitalized ('Pending')
@@ -116,11 +157,20 @@
116 157 backendFilters.sortColumn = GoingsOn.tasksFilter.getSortColumn();
117 158 backendFilters.sortDirection = GoingsOn.tasksFilter.getSortDirection();
118 159
160 + // Reset paging for the new filter/sort and fetch the first page.
161 + taskPaging.baseFilters = backendFilters;
162 + taskPaging.loadedCount = 0;
163 + taskPaging.total = 0;
164 +
119 165 let response;
120 166 try {
121 - // Fetch filtered and sorted tasks from backend
122 - response = await GoingsOn.api.tasks.listFiltered(backendFilters);
123 - // Update cache with filtered results
167 + // Fetch the first page of filtered/sorted tasks from the backend.
168 + response = await GoingsOn.api.tasks.listFiltered({
169 + ...backendFilters,
170 + offset: 0,
171 + limit: TASK_PAGE_SIZE,
172 + });
173 + // Update cache with the first page.
124 174 GoingsOn.state.set('tasks', response.tasks);
125 175 } catch (err) {
126 176 container.innerHTML = `<div class="loading loading--error">Failed to load tasks. <button class="btn-link" onclick="GoingsOn.tasks.renderFilteredTasks()">Try again</button></div>`;
@@ -165,13 +215,14 @@
165 215 return;
166 216 }
167 217
168 - // Phase 7 Tier 2 #9 — surface the count so users can see "247 tasks"
169 - // and notice when the 500-row server cap is hit.
170 - _updateTaskCount(response.total, response.tasks.length);
218 + // Backend already returns tasks sorted by sortColumn/sortDirection.
219 + const displayTasks = _decorateTasks(response.tasks);
220 + taskPaging.loadedCount = displayTasks.length;
221 + taskPaging.total = response.total;
171 222
172 - // Backend already returns tasks sorted by sortColumn/sortDirection
173 - let displayTasks = response.tasks;
174 - displayTasks = displayTasks.map(t => ({ ...t, displayDescription: t.description }));
223 + // Phase 7 Tier 2 #9 — surface the count so users can see "247 tasks";
224 + // reads "X of N" while later pages stream in on scroll.
225 + _updateTaskCount(taskPaging.total, taskPaging.loadedCount);
175 226
176 227 // Update state with sorted tasks (already sorted by backend)
177 228 GoingsOn.state.set('tasks', displayTasks);
@@ -186,7 +237,8 @@
186 237 const paginationEl = document.getElementById('task-pagination');
187 238 if (paginationEl) paginationEl.classList.add('hidden');
188 239
189 - // Initialize or refresh virtual scroller
240 + // Initialize or refresh virtual scroller. onNeedMore streams later pages
241 + // in as the user scrolls toward the tail.
190 242 if (!taskScroller) {
191 243 taskScroller = new GoingsOn.VirtualScroller({
192 244 container: container,
@@ -194,6 +246,7 @@
194 246 getItems: () => GoingsOn.state.tasks || [],
195 247 rowHeight: { estimated: 52, measure: true },
196 248 overscan: 5,
249 + onNeedMore: loadMoreTasks,
197 250 });
198 251 } else {
199 252 taskScroller.refresh();
@@ -23,6 +23,14 @@ class VirtualScroller {
23 23 this.shouldMeasure = config.rowHeight?.measure !== false;
24 24 this.overscan = config.overscan ?? 5;
25 25 this.onRender = config.onRender || null;
26 + // Optional infinite-scroll hook. Fired once (until refresh()) when the
27 + // rendered window comes within needMoreThreshold rows of the end, so the
28 + // caller can fetch and append the next page. The caller re-arms by
29 + // calling refresh() after appending; if there is no more data it simply
30 + // returns without refreshing and the trigger stays disarmed.
31 + this.onNeedMore = config.onNeedMore || null;
32 + this.needMoreThreshold = config.needMoreThreshold ?? (this.overscan * 4);
33 + this._loadingMore = false;
26 34
27 35 // State
28 36 this.items = [];
@@ -254,6 +262,15 @@ class VirtualScroller {
254 262 const visibleItems = this.items.slice(startIndex, endIndex);
255 263 this.onRender(visibleItems, startIndex);
256 264 }
265 +
266 + // Infinite-scroll: if we are rendering near the tail of the loaded data,
267 + // ask the caller for the next page. Disarm immediately so the request
268 + // fires at most once until refresh() re-arms it.
269 + if (this.onNeedMore && !this._loadingMore &&
270 + endIndex >= this.items.length - this.needMoreThreshold) {
271 + this._loadingMore = true;
272 + this.onNeedMore();
273 + }
257 274 }
258 275
259 276 /**
@@ -311,6 +328,9 @@ class VirtualScroller {
311 328 */
312 329 refresh() {
313 330 if (this.isDestroyed) return;
331 + // Re-arm the infinite-scroll trigger: the data set just changed (likely
332 + // a freshly appended page), so a new near-tail render may need more.
333 + this._loadingMore = false;
314 334 this._render(true);
315 335 }
316 336
@@ -17,7 +17,7 @@ use crate::email::{uses_jmap, ImapClient, SmtpClient};
17 17 use crate::state::{AppState, DESKTOP_USER_ID};
18 18 use goingson_db_sqlite::utils::is_valid_email;
19 19 use goingson_core::date_utils::{format_relative_future, format_elapsed_time};
20 - use super::{ApiError, LinkProjectInput, OptionNotFound, ResultApiError, SnoozeInput, WaitingInput};
20 + use super::{ApiError, LinkProjectInput, OptionApiError, OptionNotFound, ResultApiError, SnoozeInput, WaitingInput};
21 21 use super::email_account::{uses_oauth_imap, get_account_password, get_valid_access_token};
22 22
23 23 // ============ IMAP Client Helper ============
@@ -140,6 +140,8 @@ pub struct EmailResponse {
140 140 pub subject: String,
141 141 pub body: String,
142 142 pub html_body: Option<String>,
143 + /// Whether the body was truncated at sync and can be re-fetched in full.
144 + pub body_truncated: bool,
143 145 pub is_read: bool,
144 146 pub is_archived: bool,
145 147 pub received_at: chrono::DateTime<Utc>,
@@ -218,6 +220,7 @@ impl From<Email> for EmailResponse {
218 220 subject: e.subject,
219 221 body: e.body,
220 222 html_body: e.html_body,
223 + body_truncated: e.body_truncated,
221 224 is_read: e.is_read,
222 225 is_archived: e.is_archived,
223 226 received_at: e.received_at,
@@ -333,6 +336,35 @@ pub async fn get_email(state: State<'_, Arc<AppState>>, id: EmailId) -> Result<O
333 336 Ok(email.map(EmailResponse::from))
334 337 }
335 338
339 + /// Fetches the full body of a JMAP email whose body was truncated at sync
340 + /// (>100KB). Re-fetches via the provider, stores the full body, clears the
341 + /// truncation flag, and returns the body. If the email was not truncated, the
342 + /// already-stored body is returned unchanged.
343 + #[tauri::command]
344 + #[instrument(skip_all)]
345 + pub async fn fetch_email_full_body(state: State<'_, Arc<AppState>>, id: EmailId) -> Result<String, ApiError> {
346 + let email = state.emails.get_by_id(id, DESKTOP_USER_ID).await?
347 + .or_not_found("email", id)?;
348 +
349 + if !email.body_truncated {
350 + return Ok(email.body);
351 + }
352 +
353 + let jmap_id = email.jmap_id
354 + .or_api_err(|| ApiError::bad_request("Email has no JMAP id; full body unavailable"))?;
355 + let account_id = email.email_account_id
356 + .or_api_err(|| ApiError::bad_request("Email has no account; cannot re-fetch body"))?;
357 + let account = state.email_accounts.get_by_id(account_id, DESKTOP_USER_ID).await?
358 + .or_not_found("emailAccount", account_id)?;
359 +
360 + let mut client = super::email_sync::build_jmap_client(state.inner(), &account).await?;
361 + let body = client.fetch_email_full_body(&jmap_id).await
362 + .map_api_err("Failed to fetch full email body", ApiError::external_service)?;
363 +
364 + state.emails.set_full_body(id, DESKTOP_USER_ID, &body).await?;
365 + Ok(body)
366 + }
367 +
336 368 /// Opens an email in the system's default web browser.
337 369 #[tauri::command]
338 370 #[instrument(skip_all)]
@@ -665,6 +697,8 @@ async fn send_email_inner(state: &Arc<AppState>, input: SendEmailInput) -> Resul
665 697 email_account_id: Some(input.account_id),
666 698 is_outgoing: true,
667 699 attachment_meta: None,
700 + body_truncated: false,
701 + jmap_id: None,
668 702 };
669 703
670 704 let saved = state.emails.create_with_tracking(DESKTOP_USER_ID, new_email).await?;
@@ -241,6 +241,8 @@ async fn process_folder_emails(
241 241 imap_uid: Some(p.imap_uid as i64),
242 242 is_archived,
243 243 attachment_meta,
244 + body_truncated: false,
245 + jmap_id: None,
244 246 }
245 247 }).collect();
246 248 (fetched, all_pending)
@@ -262,16 +264,17 @@ async fn process_folder_emails(
262 264 Ok(save_result.emails_saved)
263 265 }
264 266
265 - /// Syncs a JMAP email account.
267 + /// Builds a JMAP client for an account, resolving and (if needed) refreshing the
268 + /// OAuth access token. Shared by the sync path and on-demand fetches (e.g. lazy
269 + /// full-body load) so token handling lives in exactly one place.
266 270 #[instrument(skip_all)]
267 - async fn sync_jmap_account_inner(
271 + pub(crate) async fn build_jmap_client(
268 272 state: &Arc<AppState>,
269 273 account: &EmailAccount,
270 - id: EmailAccountId,
271 - full_sync: Option<bool>,
272 - ) -> Result<SyncResponse, ApiError> {
274 + ) -> Result<JmapClient, ApiError> {
273 275 let session_url = account.jmap_session_url.as_ref()
274 276 .or_api_err(|| ApiError::bad_request("No JMAP session URL configured"))?;
277 + let id = account.id;
275 278
276 279 // Token resolution order: (1) in-memory keychain cache, (2) DB-stored token,
277 280 // (3) account field. The keychain is preferred because token refreshes update
@@ -316,8 +319,18 @@ async fn sync_jmap_account_inner(
316 319 }
317 320 }
318 321
319 - let mut client = JmapClient::new(session_url, &access_token)
320 - .map_err(ApiError::external_service)?;
322 + JmapClient::new(session_url, &access_token).map_err(ApiError::external_service)
323 + }
324 +
325 + /// Syncs a JMAP email account.
326 + #[instrument(skip_all)]
327 + async fn sync_jmap_account_inner(
328 + state: &Arc<AppState>,
329 + account: &EmailAccount,
330 + id: EmailAccountId,
331 + full_sync: Option<bool>,
332 + ) -> Result<SyncResponse, ApiError> {
333 + let mut client = build_jmap_client(state, account).await?;
321 334 let mut debug_parts = Vec::new();
322 335
323 336 let since = if full_sync.unwrap_or(false) { None } else { account.last_sync_at };
@@ -354,6 +367,8 @@ async fn sync_jmap_account_inner(
354 367 imap_uid: None,
355 368 is_archived: false,
356 369 attachment_meta: None,
370 + body_truncated: p.body_truncated,
371 + jmap_id: Some(p.jmap_id),
357 372 }).collect();
358 373
359 374 let inbox_result = process_fetched_emails(
@@ -389,6 +404,8 @@ async fn sync_jmap_account_inner(
389 404 imap_uid: None,
390 405 is_archived: true,
391 406 attachment_meta: None,
407 + body_truncated: p.body_truncated,
408 + jmap_id: Some(p.jmap_id),
392 409 }).collect();
393 410
394 411 let archive_result = process_fetched_emails(
@@ -26,6 +26,8 @@ pub struct JmapParsedEmail {
26 26 pub subject: String,
27 27 /// Body text
28 28 pub body: String,
29 + /// Whether the body was truncated by the 100KB sync cap.
30 + pub body_truncated: bool,
29 31 /// Received date
30 32 pub date: DateTime<Utc>,
31 33 /// Whether email has been read
@@ -118,7 +120,7 @@ impl JmapClient {
118 120 .map(|a| a.to_string())
119 121 .unwrap_or_default();
120 122
121 - let body = Self::extract_body(&email);
123 + let (body, body_truncated) = Self::extract_body(&email);
122 124
123 125 let is_read = email
124 126 .keywords
@@ -154,6 +156,7 @@ impl JmapClient {
154 156 to,
155 157 subject: email.subject.unwrap_or_default(),
156 158 body,
159 + body_truncated,
157 160 date: email.received_at.unwrap_or_else(Utc::now),
158 161 is_read,
159 162 });
@@ -323,21 +326,49 @@ impl JmapClient {
323 326 Err("Failed to send email: no response".to_string())
324 327 }
325 328
326 - /// Extracts the text body from a JMAP email.
327 - fn extract_body(email: &JmapEmail) -> String {
329 + /// Extracts the text body from a JMAP email, plus whether it was truncated
330 + /// by the server's `maxBodyValueBytes` cap.
331 + fn extract_body(email: &JmapEmail) -> (String, bool) {
328 332 // First try to get from bodyValues using textBody part IDs
329 333 if let (Some(body_values), Some(text_body)) = (&email.body_values, &email.text_body) {
330 334 for part in text_body {
331 335 if let Some(part_id) = &part.part_id {
332 336 if let Some(body_value) = body_values.get(part_id) {
333 - return body_value.value.clone();
337 + return (body_value.value.clone(), body_value.is_truncated.unwrap_or(false));
334 338 }
335 339 }
336 340 }
337 341 }
338 342
339 - // Fall back to preview
340 - email.preview.clone().unwrap_or_default()
343 + // Fall back to preview (always a short, non-truncated summary)
344 + (email.preview.clone().unwrap_or_default(), false)
345 + }
346 +
347 + /// Re-fetches a single email's full text body by JMAP id, without the 100KB
348 + /// sync cap. Used to lazily load a body that was truncated during sync.
349 + pub async fn fetch_email_full_body(&mut self, jmap_id: &str) -> Result<String, String> {
350 + let account_id = self.account_id().await?;
351 +
352 + let get_args = json!({
353 + "accountId": account_id,
354 + "ids": [jmap_id],
355 + "properties": ["id", "bodyValues", "textBody", "preview"],
356 + "fetchTextBodyValues": true,
357 + // 25 MB ceiling — large enough for any realistic message body.
358 + "maxBodyValueBytes": 25_000_000
359 + });
360 +
361 + let get_response = self.call("Email/get", get_args).await?;
362 + let emails: Vec<JmapEmail> = serde_json::from_value(get_response.data["list"].clone())
363 + .map_err(|e| format!("Failed to parse email: {}", e))?;
364 +
365 + let email = emails
366 + .into_iter()
367 + .next()
368 + .ok_or_else(|| "Email not found".to_string())?;
369 +
370 + let (body, _truncated) = Self::extract_body(&email);
371 + Ok(body)
341 372 }
342 373 }
343 374
@@ -373,7 +404,7 @@ mod tests {
373 404 },
374 405 "textBody": [{"partId": "1", "type": "text/plain"}]
375 406 }));
376 - let body = JmapClient::extract_body(&email);
407 + let (body, _truncated) = JmapClient::extract_body(&email);
377 408 assert_eq!(body, "Hello, this is the full body text.");
378 409 }
379 410
@@ -390,7 +421,7 @@ mod tests {
390 421 {"partId": "2", "type": "text/plain"}
391 422 ]
392 423 }));
393 - let body = JmapClient::extract_body(&email);
424 + let (body, _truncated) = JmapClient::extract_body(&email);
394 425 assert_eq!(body, "Part 1 text");
395 426 }
396 427
@@ -400,7 +431,7 @@ mod tests {
400 431 "id": "e3",
401 432 "preview": "This is the preview text..."
402 433 }));
403 - let body = JmapClient::extract_body(&email);
434 + let (body, _truncated) = JmapClient::extract_body(&email);
404 435 assert_eq!(body, "This is the preview text...");
405 436 }
406 437
@@ -409,7 +440,7 @@ mod tests {
409 440 let email = email_from_json(json!({
410 441 "id": "e4"
411 442 }));
412 - let body = JmapClient::extract_body(&email);
443 + let (body, _truncated) = JmapClient::extract_body(&email);
413 444 assert_eq!(body, "");
414 445 }
415 446
@@ -423,7 +454,7 @@ mod tests {
423 454 },
424 455 "preview": "Fallback preview"
425 456 }));
426 - let body = JmapClient::extract_body(&email);
457 + let (body, _truncated) = JmapClient::extract_body(&email);
427 458 assert_eq!(body, "Fallback preview");
428 459 }
429 460
@@ -435,7 +466,7 @@ mod tests {
435 466 "textBody": [{"partId": "1", "type": "text/plain"}],
436 467 "preview": "Fallback preview"
437 468 }));
438 - let body = JmapClient::extract_body(&email);
469 + let (body, _truncated) = JmapClient::extract_body(&email);
439 470 assert_eq!(body, "Fallback preview");
440 471 }
441 472
@@ -450,7 +481,7 @@ mod tests {
450 481 "textBody": [{"partId": "1", "type": "text/plain"}],
451 482 "preview": "Fallback"
452 483 }));
453 - let body = JmapClient::extract_body(&email);
484 + let (body, _truncated) = JmapClient::extract_body(&email);
454 485 assert_eq!(body, "Fallback");
455 486 }
456 487
@@ -464,7 +495,7 @@ mod tests {
464 495 "textBody": [{"type": "text/plain"}],
465 496 "preview": "Preview text"
466 497 }));
467 - let body = JmapClient::extract_body(&email);
498 + let (body, _truncated) = JmapClient::extract_body(&email);
468 499 // No partId on the text body part, so can't look up in bodyValues
469 500 assert_eq!(body, "Preview text");
470 501 }
@@ -478,7 +509,7 @@ mod tests {
478 509 },
479 510 "textBody": [{"partId": "1", "type": "text/plain"}]
480 511 }));
481 - let body = JmapClient::extract_body(&email);
512 + let (body, _truncated) = JmapClient::extract_body(&email);
482 513 // Returns empty string from bodyValues (not falling through to preview)
483 514 assert_eq!(body, "");
484 515 }
@@ -495,7 +526,7 @@ mod tests {
495 526 {"partId": "2", "type": "text/plain"}
496 527 ]
497 528 }));
498 - let body = JmapClient::extract_body(&email);
529 + let (body, _truncated) = JmapClient::extract_body(&email);
499 530 // First part has no partId, skipped; second part matches
500 531 assert_eq!(body, "Second part body");
501 532 }
@@ -514,6 +545,7 @@ mod tests {
514 545 to: "bob@example.com".to_string(),
515 546 subject: "Test Subject".to_string(),
516 547 body: "Test body".to_string(),
548 + body_truncated: false,
517 549 date: chrono::Utc::now(),
518 550 is_read: false,
519 551 };
@@ -522,6 +554,31 @@ mod tests {
522 554 assert!(!parsed.is_read);
523 555 }
524 556
557 + #[test]
558 + fn extract_body_reports_truncation_flag() {
559 + // A truncated bodyValue must surface as truncated=true so the reader can
560 + // offer a lazy full-body fetch.
561 + let email = email_from_json(json!({
562 + "id": "e_trunc",
563 + "bodyValues": {
564 + "1": {"value": "partial body", "isTruncated": true}
565 + },
566 + "textBody": [{"partId": "1", "type": "text/plain"}]
567 + }));
568 + let (body, truncated) = JmapClient::extract_body(&email);
569 + assert_eq!(body, "partial body");
570 + assert!(truncated, "isTruncated=true must be reported");
571 +
572 + // A normal (untruncated) body reports false.
573 + let email2 = email_from_json(json!({
574 + "id": "e_ok",
575 + "bodyValues": {"1": {"value": "complete"}},
576 + "textBody": [{"partId": "1", "type": "text/plain"}]
577 + }));
578 + let (_b, truncated2) = JmapClient::extract_body(&email2);
579 + assert!(!truncated2);
580 + }
581 +
525 582 // ---- Email deserialization for read/unread detection ----
526 583
527 584 #[test]
@@ -110,6 +110,7 @@ macro_rules! all_commands {
110 110 $crate::commands::list_emails,
111 111 $crate::commands::list_emails_threaded,
112 112 $crate::commands::get_email,
113 + $crate::commands::fetch_email_full_body,
113 114 $crate::commands::open_email_in_browser,
114 115 $crate::commands::create_email,
115 116 $crate::commands::send_email,