Skip to main content

max / goingson

Make project email lists body-less to bound memory list_by_project and list_unlinked selected full email bodies (EMAIL_SELECT_ COLUMNS) with no LIMIT, materializing every body into RAM. Their callers -- the project dashboard email list and the "link email to project" picker -- render only subject/from/date and open the reader (which re-fetches the body via get_by_id) on click, so the bodies are never used. Switch both to EMAIL_LIST_COLUMNS (blank body/html_body, body_truncated=1) plus LIMIT EMAIL_LIST_CAP, matching list_metadata's Perf S3 rule. Note the fuzz finding said to "cap list_all", but list_all is the backup path (backup_ scheduler) that must load all bodies -- a LIMIT there would truncate backups; the UI list methods are the real fix. Adds body-less regression tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-07 14:44 UTC
Commit: fc62ae02ffc7143ede1e0480e21ab38df0abbc9a
Parent: 9d27372
2 files changed, +93 insertions, -4 deletions
@@ -280,9 +280,13 @@ impl EmailRepository for SqliteEmailRepository {
280 280
281 281 #[tracing::instrument(skip_all)]
282 282 async fn list_by_project(&self, user_id: UserId, project_id: ProjectId) -> Result<Vec<Email>> {
283 + // Body-less + capped: the project dashboard renders only subject/from/date
284 + // and opens the reader (which re-fetches the full body via `get_by_id`) on
285 + // click, so this must not materialize every body into RAM (Perf S3, same
286 + // rule as `list_metadata`). `EMAIL_LIST_COLUMNS` forces `body_truncated=1`.
283 287 let query = format!(
284 - "SELECT {} FROM emails e LEFT JOIN projects p ON e.project_id = p.id AND p.user_id = ? WHERE e.user_id = ? AND e.project_id = ? ORDER BY e.received_at DESC",
285 - EMAIL_SELECT_COLUMNS
288 + "SELECT {} FROM emails e LEFT JOIN projects p ON e.project_id = p.id AND p.user_id = ? WHERE e.user_id = ? AND e.project_id = ? ORDER BY e.received_at DESC LIMIT {}",
289 + EMAIL_LIST_COLUMNS, EMAIL_LIST_CAP
286 290 );
287 291 let rows = sqlx::query_as::<_, EmailRow>(&query).bind(user_id.to_string()).bind(user_id.to_string()).bind(project_id.to_string()).fetch_all(&self.pool).await.map_err(CoreError::database)?;
288 292 rows.into_iter().map(Email::try_from).collect()
@@ -313,9 +317,13 @@ impl EmailRepository for SqliteEmailRepository {
313 317
314 318 #[tracing::instrument(skip_all)]
315 319 async fn list_unlinked(&self, user_id: UserId) -> Result<Vec<Email>> {
320 + // Body-less + capped: the sole caller is the "link email to project" picker,
321 + // which shows only subject/from. No body needed, and an unbounded mailbox
322 + // must not load every body into RAM (Perf S3). `EMAIL_LIST_COLUMNS` forces
323 + // `body_truncated=1` so any later reader re-fetches the full body.
316 324 let query = format!(
317 - "SELECT {} FROM emails e LEFT JOIN projects p ON e.project_id = p.id AND p.user_id = ? WHERE e.user_id = ? AND e.project_id IS NULL AND e.is_archived = 0 ORDER BY e.received_at DESC",
318 - EMAIL_SELECT_COLUMNS
325 + "SELECT {} FROM emails e LEFT JOIN projects p ON e.project_id = p.id AND p.user_id = ? WHERE e.user_id = ? AND e.project_id IS NULL AND e.is_archived = 0 ORDER BY e.received_at DESC LIMIT {}",
326 + EMAIL_LIST_COLUMNS, EMAIL_LIST_CAP
319 327 );
320 328 let rows = sqlx::query_as::<_, EmailRow>(&query).bind(user_id.to_string()).bind(user_id.to_string()).fetch_all(&self.pool).await.map_err(CoreError::database)?;
321 329 rows.into_iter().map(Email::try_from).collect()
@@ -327,3 +327,84 @@ async fn test_mark_all_read() {
327 327 let count = repo.count_unread(user_id).await.expect("Failed to count");
328 328 assert_eq!(count, 0);
329 329 }
330 +
331 + /// The project-dashboard email list (`list_by_project`) renders only
332 + /// subject/from/date and opens the reader (re-fetching the body via
333 + /// `get_by_id`) on click, so it must return body-less rows -- otherwise every
334 + /// email body materializes into RAM. Mirrors `list_metadata`'s Perf S3 rule.
335 + #[tokio::test]
336 + async fn list_by_project_is_body_less() {
337 + use goingson_core::{NewProject, ProjectRepository};
338 + use goingson_db_sqlite::SqliteProjectRepository;
339 +
340 + let pool = common::setup_test_db().await;
341 + let user_id = common::create_test_user(&pool).await;
342 + let projects = SqliteProjectRepository::new(pool.clone());
343 + let emails = SqliteEmailRepository::new(pool);
344 +
345 + let project = projects
346 + .create(
347 + user_id,
348 + NewProject {
349 + name: "Dashboard project".into(),
350 + description: String::new(),
351 + project_type: Default::default(),
352 + status: Default::default(),
353 + },
354 + )
355 + .await
356 + .expect("create project");
357 +
358 + emails
359 + .create(
360 + user_id,
361 + NewEmail {
362 + project_id: Some(project.id),
363 + from_address: "s@example.com".to_string(),
364 + to_address: "r@example.com".to_string(),
365 + subject: "Linked".to_string(),
366 + body: "a very long body that must not be loaded for a list view".to_string(),
367 + is_read: false,
368 + received_at: Some(Utc::now()),
369 + },
370 + )
371 + .await
372 + .expect("create email");
373 +
374 + let listed = emails.list_by_project(user_id, project.id).await.expect("list_by_project");
375 + assert_eq!(listed.len(), 1);
376 + assert_eq!(listed[0].subject, "Linked", "metadata still returned");
377 + assert!(listed[0].body.is_empty(), "body must not be materialized in a list view");
378 + assert!(listed[0].html_body.is_none(), "html body must not be materialized");
379 + assert!(listed[0].body_truncated, "reader must know to re-fetch the full body");
380 + }
381 +
382 + /// `list_unlinked` feeds the "link email to project" picker (subject/from only)
383 + /// and must likewise stay body-less so an unbounded mailbox can't blow up RAM.
384 + #[tokio::test]
385 + async fn list_unlinked_is_body_less() {
386 + let pool = common::setup_test_db().await;
387 + let user_id = common::create_test_user(&pool).await;
388 + let repo = SqliteEmailRepository::new(pool);
389 +
390 + repo.create(
391 + user_id,
392 + NewEmail {
393 + project_id: None,
394 + from_address: "s@example.com".to_string(),
395 + to_address: "r@example.com".to_string(),
396 + subject: "Unlinked".to_string(),
397 + body: "body that should never be loaded into the picker".to_string(),
398 + is_read: false,
399 + received_at: Some(Utc::now()),
400 + },
401 + )
402 + .await
403 + .expect("create email");
404 +
405 + let listed = repo.list_unlinked(user_id).await.expect("list_unlinked");
406 + assert_eq!(listed.len(), 1);
407 + assert_eq!(listed[0].subject, "Unlinked");
408 + assert!(listed[0].body.is_empty(), "body must not be materialized");
409 + assert!(listed[0].body_truncated, "reader must know to re-fetch the full body");
410 + }