max / goingson
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
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), |