Skip to main content

max / goingson

Compose carries files, and what it sends is the blob Finishes attachments. Picking is Action::by_host like every other file pick in the app; what lands is a row against the draft with the bytes in the content-addressed blob store. A MESSAGE IS THE THIRD PARENT. NewAttachment already had source_email_id and attach_path always set it None, taking only a task and a project. It takes an email now, verified the same way and required to be a draft: attaching to a message already sent or received is a row nothing would ever read. AttachmentRepository gains list_for_email beside list_for_task and list_for_project. THE DRAINER SENDS BLOBS, NOT PATHS, and that is the point rather than a tidy-up. A message queued at nine may go at five, and the file it was written from can be moved, renamed or deleted in between; a blob is named by its hash and blob_gc keeps it while a row references it. There is a test that deletes the picked file after queueing and shows the message still carries its contents. The recipient sees the row's filename, since a blob is named by its hash. Removing a file is a row delete. The blob is left for the collector, which is what blob_gc is for and what makes this not a disk operation. A queued message lists its files and offers no way to change them: the drainer may be reading them. Taking it back gives the controls back. THE TOTAL SIZE IS CHECKED AT QUEUE TIME as well as at send. send.rs refuses over 25 MB, and under an outbox that refusal lands as a send_error on a row nobody is looking at, minutes or hours after the person walked away. Said at queue time it is something they can act on. Both checks stay, because the send path is reached by more than this screen. Four tests, including the one that matters: the blob outlives the file. Closes goingson 03bbf7e9.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-22 19:28 UTC
Signed with PGP, not checked
Commit: 9b37c84a47eca6e75dc4710fad13b73e0caf73db
Parent: 2cc54be
7 files changed, +407 insertions, -34 deletions
@@ -153,6 +153,32 @@
153 153 continue;
154 154 };
155 155
156 + // The blobs, not the paths the files were picked from. A queued
157 + // message may go hours after it was written and that file can have been
158 + // moved, renamed or deleted; a blob is content-addressed under
159 + // `<data_dir>/blobs` and `blob_gc` keeps it while a row references it.
160 + //
161 + // The row's `filename` is what the recipient sees, because a blob is
162 + // named by its hash.
163 + let attachments = match state.attachments.list_for_email(email.id, DESKTOP_USER_ID) {
164 + Ok(files) => files,
165 + Err(error) => {
166 + error!(
167 + "Outbox drainer: could not read {}'s files: {error}",
168 + email.id
169 + );
170 + continue;
171 + }
172 + };
173 + let attachment_paths = attachments
174 + .iter()
175 + .map(|file| {
176 + crate::commands::attachment::blob_path(&state.data_dir, &file.blob_hash)
177 + .to_string_lossy()
178 + .into_owned()
179 + })
180 + .collect();
181 +
156 182 let input = SendEmailInput {
157 183 account_id,
158 184 to_address: email.to.clone(),
@@ -164,7 +190,7 @@
164 190 in_reply_to: email.in_reply_to.clone(),
165 191 references: None,
166 192 thread_id: email.thread_id.clone(),
167 - attachment_paths: Vec::new(),
193 + attachment_paths,
168 194 };
169 195
170 196 match crate::commands::send_email_inner(state, input).await {
@@ -138,12 +138,15 @@
138 138 state: &AppState,
139 139 task_id: Option<TaskId>,
140 140 project_id: Option<ProjectId>,
141 + email_id: Option<goingson_core::EmailId>,
141 142 file_path: &str,
142 143 ) -> Result<goingson_core::Attachment, AttachFailure> {
143 - // Validate at least one parent
144 - if task_id.is_none() && project_id.is_none() {
144 + // Validate at least one parent. A message is the third, added 2026-08-22 so
145 + // compose can carry files: a draft's attachments are rows against it, and
146 + // the outbox drainer reads them at send time.
147 + if task_id.is_none() && project_id.is_none() && email_id.is_none() {
145 148 return Err(AttachFailure::Refused(
146 - "Either a task or a project is required".to_owned(),
149 + "Either a task, a project or a message is required".to_owned(),
147 150 ));
148 151 }
149 152
@@ -174,6 +177,22 @@
174 177 });
175 178 }
176 179
180 + // A message parent is verified the same way and for the same reason, and it
181 + // must be a draft: attaching to a message that has already been sent or
182 + // received is a row nothing would ever read.
183 + if let Some(eid) = email_id
184 + && !state
185 + .emails
186 + .get_by_id(eid, DESKTOP_USER_ID)
187 + .map_err(|error| AttachFailure::Failed(error.to_string()))?
188 + .is_some_and(|email| email.is_draft)
189 + {
190 + return Err(AttachFailure::Missing {
191 + resource: "draft",
192 + id: eid.to_string(),
193 + });
194 + }
195 +
177 196 let source_path = Path::new(file_path);
178 197
179 198 // Validate path exists and is a file
@@ -262,7 +281,7 @@
262 281 file_size,
263 282 mime_type,
264 283 blob_hash: hash,
265 - source_email_id: None,
284 + source_email_id: email_id,
266 285 },
267 286 )
268 287 .map_err(|error| {
@@ -351,10 +370,11 @@
351 370 file_path: String,
352 371 ) -> Result<AttachmentResponse, ApiError> {
353 372 let owned = Arc::clone(&state);
354 - let attachment =
355 - tokio::task::spawn_blocking(move || attach_path(&owned, task_id, project_id, &file_path))
356 - .await
357 - .map_err(|e| ApiError::internal(format!("Attachment task panicked: {e}")))??;
373 + let attachment = tokio::task::spawn_blocking(move || {
374 + attach_path(&owned, task_id, project_id, None, &file_path)
375 + })
376 + .await
377 + .map_err(|e| ApiError::internal(format!("Attachment task panicked: {e}")))??;
358 378
359 379 Ok(to_response(attachment, &state.data_dir))
360 380 }
@@ -56,13 +56,26 @@
56 56 //! An abandoned blank draft is the cost, and it is Eudora's cost too: an empty
57 57 //! message in Out that you throw away. Discard is one press.
58 58 //!
59 - //! # What is not here
59 + //! # Attachments are blobs, because a queued message outlives a path
60 60 //!
61 - //! **Attachments.** `Attached:` is drawn and says what the message carries, and
62 - //! there is no control to add one. `send_email_inner` takes
63 - //! `attachment_paths: Vec<String>` — paths on this machine, read at send time —
64 - //! and a described file field carries bytes. Bridging those is a real piece of
65 - //! work rather than a line, and it is filed rather than half-done.
61 + //! Attaching is [`Action::by_host`], like every other file pick in this app:
62 + //! `frontend/js/host.js` opens the dialog and posts the path, and
63 + //! `attach_path` hashes the bytes into the content-addressed blob store and
64 + //! writes a row against this draft.
65 + //!
66 + //! What the drainer sends is the blob, not the path. That is not an
67 + //! optimisation: a message queued at nine may go at five, and the file it was
68 + //! written from can be moved, renamed or deleted in between. A blob cannot, and
69 + //! `blob_gc` keeps it alive while a row references it. The recipient sees the
70 + //! row's `filename`, since a blob is named by its hash.
71 + //!
72 + //! The total size is checked here rather than only at send time, where
73 + //! `send.rs` also checks it. Under an outbox the send-time refusal lands as a
74 + //! `send_error` on a row nobody is looking at, minutes or hours after the
75 + //! person walked away. Both checks stay: the send path is reached by more than
76 + //! this screen.
77 + //!
78 + //! # What is not here
66 79 //!
67 80 //! **A window of its own.** [`Frame`](quasi_router::Frame) is what a mount puts
68 81 //! around a screen, and a mount is a `Webview`; this app builds one. A compose
@@ -102,6 +115,14 @@
102 115 /// The instant Queue Later asks for.
103 116 const SEND_AFTER: &str = "send_after";
104 117
118 + /// What a message's files may come to, together.
119 + ///
120 + /// The same number `commands::email::send` enforces, stated here because this
121 + /// is where it can still be said to somebody. Kept as two checks rather than
122 + /// one: the send path is reached by more than this screen, and a limit that
123 + /// only the screen enforced would be a limit the drainer could walk past.
124 + const MAX_TOTAL_ATTACHMENT_BYTES: i64 = 25 * 1024 * 1024;
125 +
105 126 /// A new message: make the draft, then go to it.
106 127 fn start(state: &AppState, _request: quasi_router::Request) -> Result<Response, RouteError> {
107 128 let draft = save(state, EmailId::new(), Fields::default())?;
@@ -233,19 +254,23 @@
233 254 "Bcc",
234 255 draft.bcc_address.clone().unwrap_or_default(),
235 256 ))
236 - .with(text(SUBJECT, "Subject", draft.subject.clone()))
237 - .with(attached(&draft))
238 - .with(Node::field(
239 - Field {
240 - value: Some(draft.body.clone()),
241 - ..Field::new(makeover_layout::FieldKind::Textarea, MESSAGE, "Message")
242 - }
243 - .changes(writes(MESSAGE)),
244 - ));
257 + .with(text(SUBJECT, "Subject", draft.subject.clone()));
245 258
246 259 // The verbs. Not a form's submit: the draft is already saved, so these act
247 260 // on a thing that exists. See the module header.
248 261 let queued = draft.is_queued();
262 +
263 + // Attached is the last header row, then the body under it, which is the
264 + // order Eudora drew and the reason the attachments bar has nowhere else to
265 + // go.
266 + header = header.extend(attached(state, id, queued)?);
267 + header = header.with(Node::field(
268 + Field {
269 + value: Some(draft.body.clone()),
270 + ..Field::new(makeover_layout::FieldKind::Textarea, MESSAGE, "Message")
271 + }
272 + .changes(writes(MESSAGE)),
273 + ));
249 274 if queued {
250 275 header = header
251 276 .with(Node::text(
@@ -283,19 +308,118 @@
283 308 ))
284 309 }
285 310
286 - /// The `Attached:` row.
311 + /// The `Attached:` rows, and the way to add one.
287 312 ///
288 313 /// Drawn even when empty, because it is a header row rather than a bar that
289 314 /// appears: the point of Eudora's shape is that the message says what it
290 - /// carries in the same place every time. There is no control to add one; see
291 - /// the module header.
292 - fn attached(draft: &goingson_core::Email) -> Node {
293 - let names = draft.attachment_meta.as_deref().unwrap_or_default();
294 - Node::list([Row::new("Attached").meta(if names.is_empty() {
295 - "Nothing".to_owned()
315 + /// carries in the same place every time.
316 + ///
317 + /// Attaching is [`Action::by_host`], the same as the imports and the project
318 + /// dashboard: picking a file is not describable, and `frontend/js/host.js`
319 + /// opens the dialog and posts the path back. What lands is a row against this
320 + /// draft with the bytes in the content-addressed blob store, which is what
321 + /// makes an attachment survive until the outbox drains.
322 + fn attached(state: &AppState, id: EmailId, queued: bool) -> Result<Vec<Node>, RouteError> {
323 + let files = state
324 + .attachments
325 + .list_for_email(id, DESKTOP_USER_ID)
326 + .map_err(|error| RouteError::internal(error.to_string()))?;
327 +
328 + let mut nodes = Vec::new();
329 + if files.is_empty() {
330 + nodes.push(Node::list([Row::new("Attached").meta("Nothing")]));
296 331 } else {
297 - names.to_owned()
298 - })])
332 + nodes.push(Node::list(files.iter().map(|file| {
333 + let mut row = Row::new("Attached")
334 + .secondary(file.filename.clone())
335 + .meta(size(file.file_size));
336 + // A queued message is read-only until it is taken back, so its
337 + // files are listed and not removable: the drainer may be reading
338 + // them.
339 + if !queued {
340 + row = row.act(
341 + Act::new(
342 + "Remove",
343 + Action::post(format!("/compose/{id}/detach/{}", file.id)),
344 + )
345 + .tone(Tone::Danger),
346 + );
347 + }
348 + row
349 + })));
350 + }
351 +
352 + if !queued {
353 + nodes.push(Node::Act(Act::new(
354 + "Attach a file",
355 + Action::post(format!("/compose/{id}/attach"))
356 + .by_host()
357 + .awaiting(),
358 + )));
359 + }
360 + Ok(nodes)
361 + }
362 +
363 + /// A file size in words. `data::size` says the same thing and is private to it.
364 + fn size(bytes: i64) -> String {
365 + let bytes = bytes.max(0);
366 + if bytes < 1024 {
367 + return format!("{bytes} bytes");
368 + }
369 + let units = ["KB", "MB", "GB"];
370 + let mut value = bytes as f64 / 1024.0;
371 + let mut unit = 0;
372 + while value >= 1024.0 && unit < units.len() - 1 {
373 + value /= 1024.0;
374 + unit += 1;
375 + }
376 + format!("{value:.1} {}", units[unit])
377 + }
378 +
379 + /// Attach a file the host picked.
380 + fn attach(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
381 + let id = asked_for(&request)?;
382 + let picked = request
383 + .payload
384 + .get("file")
385 + .unwrap_or_default()
386 + .trim()
387 + .to_owned();
388 + if picked.is_empty() {
389 + return Err(RouteError::conflict("No file was picked.").as_toast());
390 + }
391 +
392 + match crate::commands::attachment::attach_path(state, None, None, Some(id), &picked) {
393 + Ok(file) => Ok(Response::screen(screen(state, id)?)
394 + .toast(Tone::Success, format!("Attached {}.", file.filename))),
395 + // Ours is a fault; everything else is the person's to fix by picking a
396 + // different file, so it is said and the screen stays put. Same split
397 + // the project dashboard's attach makes.
398 + Err(crate::commands::attachment::AttachFailure::Failed(message)) => {
399 + Err(RouteError::internal(message))
400 + }
401 + Err(failure) => Err(RouteError::conflict(failure.message()).as_toast()),
402 + }
403 + }
404 +
405 + /// Take a file off the message.
406 + fn detach(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
407 + let id = asked_for(&request)?;
408 + let file = request
409 + .captures
410 + .get("file")
411 + .unwrap_or_default()
412 + .parse::<uuid::Uuid>()
413 + .map(goingson_core::AttachmentId::from)
414 + .map_err(|_| RouteError::not_found("not an attachment id"))?;
415 +
416 + state
417 + .attachments
418 + .delete(file, DESKTOP_USER_ID)
419 + .map_err(|error| RouteError::internal(error.to_string()))?;
420 + // The blob stays until `blob_gc` sees nothing references it, which is what
421 + // makes removing a file a row delete rather than a disk operation.
422 + Ok(Response::screen(screen(state, id)?).toast(Tone::Success, "Taken off."))
299 423 }
300 424
301 425 /// The screen, as an answer.
@@ -379,6 +503,24 @@
379 503 return Err(RouteError::conflict("Say who it is going to.").as_toast());
380 504 }
381 505
506 + // The total is checked here rather than at send time, which is where
507 + // `send.rs` checks it. Under an outbox that refusal would land minutes or
508 + // hours after the person walked away, as a `send_error` on a row nobody is
509 + // looking at; said now, it is something they can act on.
510 + let files = state
511 + .attachments
512 + .list_for_email(id, DESKTOP_USER_ID)
513 + .map_err(|error| RouteError::internal(error.to_string()))?;
514 + let total: i64 = files.iter().map(|file| file.file_size.max(0)).sum();
515 + if total > MAX_TOTAL_ATTACHMENT_BYTES {
516 + return Err(RouteError::conflict(format!(
517 + "The files come to {}, over the {} MB a message can carry.",
518 + size(total),
519 + MAX_TOTAL_ATTACHMENT_BYTES / (1024 * 1024)
520 + ))
521 + .as_toast());
522 + }
523 +
382 524 state
383 525 .emails
384 526 .queue_draft(id, DESKTOP_USER_ID, send_after)
@@ -499,5 +641,7 @@
499 641 .post("/compose/{id}/field", write_field)
500 642 .post("/compose/{id}/queue", queue)
501 643 .post("/compose/{id}/unqueue", unqueue)
644 + .post("/compose/{id}/attach", attach)
645 + .post("/compose/{id}/detach/{file}", detach)
502 646 .post("/compose/{id}/discard", discard)
503 647 }
@@ -365,6 +365,14 @@
365 365 /// Lists attachments for a project.
366 366 fn list_for_project(&self, project_id: ProjectId, user_id: UserId) -> Result<Vec<Attachment>>;
367 367
368 + /// Attachments belonging to one message.
369 + ///
370 + /// The third parent, beside a task and a project. A draft carries the files
371 + /// it will send, and the outbox drainer reads them here: a queued message
372 + /// may go hours after it was written, so what it sends has to be the blob
373 + /// rather than whatever is still at the path it came from.
374 + fn list_for_email(&self, email_id: EmailId, user_id: UserId) -> Result<Vec<Attachment>>;
375 +
368 376 /// Retrieves an attachment by ID.
369 377 fn get_by_id(&self, id: AttachmentId, user_id: UserId) -> Result<Option<Attachment>>;
370 378
@@ -127,6 +127,25 @@
127 127 rows.into_iter().map(Attachment::try_from).collect()
128 128 }
129 129
130 + #[tracing::instrument(skip_all)]
131 + fn list_for_email(
132 + &self,
133 + email_id: goingson_core::EmailId,
134 + user_id: UserId,
135 + ) -> Result<Vec<Attachment>> {
136 + let conn = self.db.conn()?;
137 + let rows = query_all(
138 + &conn,
139 + &format!(
140 + "SELECT {SELECT_COLS} FROM attachments WHERE source_email_id = ? AND user_id = ? ORDER BY created_at ASC"
141 + ),
142 + params![email_id.to_string(), user_id.to_string()],
143 + AttachmentRow::from_row,
144 + )?;
145 +
146 + rows.into_iter().map(Attachment::try_from).collect()
147 + }
148 +
130 149 #[tracing::instrument(skip_all)]
131 150 fn list_for_project(&self, project_id: ProjectId, user_id: UserId) -> Result<Vec<Attachment>> {
132 151 let conn = self.db.conn()?;
@@ -347,3 +347,159 @@
347 347 .is_none_or(|email| !email.is_draft)
348 348 );
349 349 }
350 +
351 + // --- Attachments -----------------------------------------------------------
352 +
353 + /// A file on disk to attach.
354 + fn a_file(name: &str, contents: &str) -> std::path::PathBuf {
355 + let dir = std::env::temp_dir().join("goingson-compose-attach-tests");
356 + std::fs::create_dir_all(&dir).unwrap();
357 + let path = dir.join(name);
358 + std::fs::write(&path, contents).unwrap();
359 + path
360 + }
361 +
362 + #[tokio::test]
363 + async fn attaching_is_the_hosts_call_and_lands_a_row_against_the_draft() {
364 + let state = state().await;
365 + let id = started(&state);
366 +
367 + // The control carries no transport: `Action::by_host`, the same as the
368 + // imports. Picking a file is not describable.
369 + let markup = html(&state, &format!("/compose/{id}"));
370 + assert!(markup.contains("Attach a file"), "{markup}");
371 + assert!(
372 + markup.contains(&format!("data-sends=\"/compose/{id}/attach\"")),
373 + "{markup}"
374 + );
375 + assert!(!markup.contains("type=\"file\""), "{markup}");
376 +
377 + let path = a_file("notes.txt", "hello");
378 + post(
379 + &state,
380 + &format!("/compose/{id}/attach"),
381 + Params::new().with("file", path.to_string_lossy().into_owned()),
382 + );
383 +
384 + let files = state
385 + .attachments
386 + .list_for_email(id, DESKTOP_USER_ID)
387 + .expect("read");
388 + assert_eq!(files.len(), 1);
389 + assert_eq!(files[0].filename, "notes.txt");
390 +
391 + // And the header row says so where it said "Nothing".
392 + let markup = html(&state, &format!("/compose/{id}"));
393 + assert!(markup.contains("notes.txt"), "{markup}");
394 + assert!(!markup.contains("Nothing"), "{markup}");
395 + }
396 +
397 + #[tokio::test]
398 + async fn a_queued_message_lists_its_files_and_offers_no_way_to_change_them() {
399 + // The drainer may be reading them.
400 + let state = state().await;
401 + let account = account(&state);
402 + let id = started(&state);
403 + for (field, value) in [
404 + ("from", account.to_string()),
405 + ("to", "them@example.com".to_owned()),
406 + ] {
407 + post(
408 + &state,
409 + &format!("/compose/{id}/field"),
410 + Params::new().with("field", field).with(field, value),
411 + );
412 + }
413 + post(
414 + &state,
415 + &format!("/compose/{id}/attach"),
416 + Params::new().with(
417 + "file",
418 + a_file("report.csv", "a,b").to_string_lossy().into_owned(),
419 + ),
420 + );
421 + post(&state, &format!("/compose/{id}/queue"), Params::new());
422 +
423 + let markup = html(&state, &format!("/compose/{id}"));
424 + assert!(markup.contains("report.csv"), "still listed: {markup}");
425 + assert!(!markup.contains("Attach a file"), "{markup}");
426 + assert!(!markup.contains("Remove"), "{markup}");
427 + }
428 +
429 + #[tokio::test]
430 + async fn removing_a_file_takes_the_row_and_leaves_the_blob_to_the_collector() {
431 + let state = state().await;
432 + let id = started(&state);
433 + post(
434 + &state,
435 + &format!("/compose/{id}/attach"),
436 + Params::new().with(
437 + "file",
438 + a_file("gone.txt", "bye").to_string_lossy().into_owned(),
439 + ),
440 + );
441 + let files = state
442 + .attachments
443 + .list_for_email(id, DESKTOP_USER_ID)
444 + .expect("read");
445 + let hash = files[0].blob_hash.clone();
446 +
447 + post(
448 + &state,
449 + &format!("/compose/{id}/detach/{}", files[0].id),
450 + Params::new(),
451 + );
452 + assert!(
453 + state
454 + .attachments
455 + .list_for_email(id, DESKTOP_USER_ID)
456 + .expect("read")
457 + .is_empty()
458 + );
459 + // The blob is `blob_gc`'s to reclaim, which is what makes removing a file a
460 + // row delete rather than a disk operation.
461 + assert!(
462 + crate::commands::attachment::blob_path(&state.data_dir, &hash).exists(),
463 + "the blob is left for the collector"
464 + );
465 + }
466 +
467 + #[tokio::test]
468 + async fn the_drainer_sends_the_blob_rather_than_the_path_it_came_from() {
469 + // THE ONE THAT MATTERS for send-later. A queued message may go hours after
470 + // it was written, and the file it was written from can be gone by then.
471 + let state = state().await;
472 + let account = account(&state);
473 + let id = started(&state);
474 + for (field, value) in [
475 + ("from", account.to_string()),
476 + ("to", "them@example.com".to_owned()),
477 + ] {
478 + post(
479 + &state,
480 + &format!("/compose/{id}/field"),
481 + Params::new().with("field", field).with(field, value),
482 + );
483 + }
484 +
485 + let path = a_file("fleeting.txt", "here for now");
486 + post(
487 + &state,
488 + &format!("/compose/{id}/attach"),
489 + Params::new().with("file", path.to_string_lossy().into_owned()),
490 + );
491 + post(&state, &format!("/compose/{id}/queue"), Params::new());
492 +
493 + // The file the person picked goes away, as it may.
494 + std::fs::remove_file(&path).expect("remove");
495 +
496 + // What the message carries is still there, under its hash.
497 + let files = state
498 + .attachments
499 + .list_for_email(id, DESKTOP_USER_ID)
500 + .expect("read");
501 + assert_eq!(files.len(), 1);
502 + let blob = crate::commands::attachment::blob_path(&state.data_dir, &files[0].blob_hash);
503 + assert!(blob.exists(), "the blob outlives the file");
504 + assert_eq!(std::fs::read_to_string(&blob).unwrap(), "here for now");
505 + }
@@ -533,7 +533,7 @@
533 533 return attachments_pane(state, id, Some("Choose a file to attach."));
534 534 }
535 535
536 - match crate::commands::attachment::attach_path(state, None, Some(id), &picked) {
536 + match crate::commands::attachment::attach_path(state, None, Some(id), None, &picked) {
537 537 Ok(attachment) => Ok(attachments_pane(state, id, None)?.toast(
538 538 makeover_layout::Tone::Success,
539 539 format!("Attached {}.", attachment.filename),