max / goingson
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 ? '↗' : '↙'; // 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, |