//! Mail attachment metadata and an independent dialog session. Files and Mail //! share bounded decoders/PDF workers; opening Mail never changes Files state. use crate::document_preview::{Preview, image_thumbnail, preview}; use crate::{ AppWindow, EmailReader, InboxState, ListAttachment, MailAttachment, MailAttachments, mail::MailMessage, }; use slint::{ComponentHandle, Model, ModelRc, Rgba8Pixel, SharedPixelBuffer, VecModel}; use std::{ cell::RefCell, collections::HashSet, rc::Rc, sync::{ Arc, atomic::{AtomicU64, Ordering}, }, }; #[cfg(mobile)] static SUSPEND: std::sync::OnceLock> = std::sync::OnceLock::new(); #[cfg(mobile)] pub(crate) fn suspend() { if let Some(suspend) = SUSPEND.get() { suspend(); } } pub(crate) fn clear(app: &AppWindow) { let ui = app.global::(); ui.invoke_command("close".into(), "".into()); ui.set_rows(ModelRc::default()); } pub(crate) fn project(app: &AppWindow, email: &MailMessage, same: bool) { let previous = same.then(|| { let rows = app.global::().get_rows(); (0..rows.row_count()) .filter_map(|index| rows.row_data(index)) .filter(|row| row.has_thumbnail) .map(|row| (row.id, row.thumbnail)) .collect::>() }); if !same { clear(app); } let mut rows = attachment_rows(&email.attachments); if let Some(previous) = previous { for row in &mut rows { if let Some(thumbnail) = previous.get(row.id.as_str()) { row.thumbnail = thumbnail.clone(); row.has_thumbnail = true; } } } let thumbnail_ids = thumbnail_ids(&email.attachments, &rows); app.global::() .set_rows(ModelRc::new(VecModel::from(rows))); for id in thumbnail_ids { app.global::() .invoke_command("thumbnail".into(), id); } } fn visible_attachment(attachment: &flectar_mail_core::models::AttachmentMeta) -> bool { !attachment.is_inline || attachment .filename .as_ref() .is_some_and(|name| !name.trim().is_empty()) } // attachment_rows preserves this filtered metadata order. Walk the two in // lockstep instead of formatting an ID for every row in a nested search. fn thumbnail_ids( attachments: &[flectar_mail_core::models::AttachmentMeta], rows: &[MailAttachment], ) -> Vec { attachments .iter() .filter(|attachment| visible_attachment(attachment)) .zip(rows) .filter(|(attachment, row)| thumbnail_candidate(attachment) && !row.has_thumbnail) .take(4) .map(|(_, row)| row.id.clone()) .collect() } fn thumbnail_candidate(attachment: &flectar_mail_core::models::AttachmentMeta) -> bool { let media = attachment .mime_type .as_deref() .unwrap_or("") .split(';') .next() .unwrap_or("") .trim(); attachment.size.is_none_or(|size| size <= 4 * 1024 * 1024) && media .get(..6) .is_some_and(|prefix| prefix.eq_ignore_ascii_case("image/")) && !media.eq_ignore_ascii_case("image/svg+xml") } async fn read_preview_bytes(file: tokio::fs::File, max_bytes: usize) -> Result, String> { use tokio::io::AsyncReadExt; let mut reader = file.take(max_bytes as u64); let mut bytes = Vec::new(); let mut chunk = [0_u8; 8 * 1024]; loop { let count = reader .read(&mut chunk) .await .map_err(|error| error.to_string())?; if count == 0 { return Ok(bytes); } let required = bytes.len() + count; if required > bytes.capacity() { let target = required .max(bytes.capacity().saturating_mul(2)) .min(max_bytes); bytes.reserve_exact(target - bytes.len()); } bytes.extend_from_slice(&chunk[..count]); } } fn attachment_rows( attachments: &[flectar_mail_core::models::AttachmentMeta], ) -> Vec { attachments .iter() .filter(|a| visible_attachment(a)) .map(|a| { let name = file_name(a); let media = media_type(a); MailAttachment { id: a.id.to_string().into(), name: name.into(), previewable: previewable(name, &media), kind: file_kind(name, &media).into(), detail: media.into(), thumbnail: Default::default(), has_thumbnail: false, } }) .collect::>() } /// A message-list row's file chip. pub(crate) fn list_attachment( attachment: &flectar_mail_core::models::AttachmentMeta, ) -> ListAttachment { let name = file_name(attachment); let media = media_type(attachment); let (stem, extension) = split_extension(name); ListAttachment { id: attachment.id.to_string().into(), stem: stem.into(), extension: extension.into(), kind: file_kind(name, &media).into(), previewable: previewable(name, &media), } } fn file_name(attachment: &flectar_mail_core::models::AttachmentMeta) -> &str { attachment .filename .as_deref() .filter(|s| !s.is_empty()) .unwrap_or("attachment") } fn media_type(attachment: &flectar_mail_core::models::AttachmentMeta) -> String { attachment .mime_type .as_deref() .unwrap_or("") .split(';') .next() .unwrap_or("") .trim() .to_ascii_lowercase() } fn previewable(name: &str, media: &str) -> bool { media == "application/pdf" || media.starts_with("image/") || media.starts_with("text/") || matches!(media, "application/json" | "application/xml") || name .as_bytes() .get(name.len().saturating_sub(4)..) .is_some_and(|suffix| suffix.eq_ignore_ascii_case(b".pdf")) } /// A name and its extension, such as "Report" and ".pdf". Only a short /// final suffix counts, so "version 1.2 final" has none. fn split_extension(name: &str) -> (&str, &str) { match name.rfind('.') { Some(dot) if dot > 0 && (2..=6).contains(&(name.len() - dot)) && name[dot + 1..].chars().all(|c| c.is_ascii_alphanumeric()) => { name.split_at(dot) } _ => (name, ""), } } /// What a file is, by its type and otherwise its name: pdf, document, /// spreadsheet, presentation, image, video, audio, archive, code, calendar /// or file. It picks the file's icon. fn file_kind(name: &str, media: &str) -> &'static str { let extension = split_extension(name) .1 .trim_start_matches('.') .to_ascii_lowercase(); let is = |types: &[&str], extensions: &[&str], families: &[&str]| { types.contains(&media) || families.iter().any(|family| media.starts_with(family)) || extensions.contains(&extension.as_str()) }; if is(&["application/pdf"], &["pdf"], &[]) { "pdf" } else if is( &[], &[ "png", "jpg", "jpeg", "gif", "webp", "heic", "heif", "bmp", "tif", "tiff", "svg", "avif", ], &["image/"], ) { "image" } else if is( &[], &[ "mp4", "mov", "m4v", "avi", "mkv", "webm", "wmv", "mpg", "mpeg", "3gp", ], &["video/"], ) { "video" } else if is( &[], &[ "mp3", "wav", "m4a", "aac", "flac", "ogg", "oga", "opus", "wma", "aif", "aiff", "amr", ], &["audio/"], ) { "audio" } else if is( &["text/calendar", "application/ics"], &["ics", "ical", "vcs"], &[], ) { "calendar" } else if is( &[ "text/csv", "text/tab-separated-values", "application/vnd.ms-excel", "application/vnd.oasis.opendocument.spreadsheet", "application/vnd.apple.numbers", ], &[ "xls", "xlsx", "xlsm", "xlsb", "ods", "csv", "tsv", "numbers", ], &["application/vnd.openxmlformats-officedocument.spreadsheetml"], ) { "spreadsheet" } else if is( &[ "application/vnd.ms-powerpoint", "application/vnd.oasis.opendocument.presentation", "application/vnd.apple.keynote", ], &["ppt", "pptx", "pps", "ppsx", "odp", "key"], &["application/vnd.openxmlformats-officedocument.presentationml"], ) { "presentation" } else if is( &[ "application/msword", "application/rtf", "text/rtf", "text/plain", "text/markdown", "application/vnd.oasis.opendocument.text", "application/vnd.apple.pages", "application/epub+zip", ], &["doc", "docx", "odt", "rtf", "txt", "md", "pages", "epub"], &["application/vnd.openxmlformats-officedocument.wordprocessingml"], ) { "document" } else if is( &[ "application/zip", "application/x-zip-compressed", "application/vnd.rar", "application/x-rar-compressed", "application/x-7z-compressed", "application/x-tar", "application/gzip", "application/x-gzip", "application/x-bzip2", "application/x-xz", "application/zstd", ], &["zip", "rar", "7z", "tar", "gz", "tgz", "bz2", "xz", "zst"], &[], ) { "archive" } else if is( &[ "application/json", "application/xml", "text/xml", "text/html", "text/css", "text/javascript", "application/javascript", "application/x-sh", ], &[ "json", "xml", "html", "htm", "css", "js", "ts", "py", "rs", "go", "java", "c", "cpp", "h", "sh", "yml", "yaml", "toml", "sql", ], &[], ) { "code" } else { "file" } } struct Session { task: Option, document: Option>>, selected: Option, } impl Session { fn stop(&mut self) { if let Some(task) = self.task.take() { task.abort(); } self.document = None; self.selected = None; } } impl Drop for Session { fn drop(&mut self) { self.stop(); } } fn close(ui: &MailAttachments<'_>, generation: &AtomicU64, session: &RefCell) { generation.fetch_add(1, Ordering::SeqCst); session.borrow_mut().stop(); ui.set_open(false); ui.set_busy(false); ui.set_image(Default::default()); ui.set_text("".into()); ui.set_status("".into()); ui.set_pages(0); ui.set_is_image(false); } pub(crate) fn register( app: &AppWindow, inbox: &Rc>, runtime: &tokio::runtime::Handle, documents: Arc, ) { let session = Rc::new(RefCell::new(Session { task: None, document: None, selected: None, })); let generation = Arc::new(AtomicU64::new(0)); let weak = app.as_weak(); #[cfg(mobile)] { let weak = weak.clone(); let _ = SUSPEND.set(Box::new(move || { let _ = weak.upgrade_in_event_loop(|app| { app.global::() .invoke_command("close".into(), "".into()) }); })); } // Profile IDs are not globally unique: compare Core identity as well as // message identity before allowing an old session to remain actionable. let context = Rc::new(RefCell::new(None::<(Arc, i32)>)); let timer = slint::Timer::default(); let state = inbox.clone(); let observed = context.clone(); let window = weak.clone(); timer.start( slint::TimerMode::Repeated, std::time::Duration::from_millis(100), move || { let Some(app) = window.upgrade() else { return; }; let current = state.borrow().core.as_ref().map(|core| { ( core.file_core(), app.global::().get_message_id(), ) }); let changed = match (&*observed.borrow(), ¤t) { (Some((a, id)), Some((b, next))) => !Arc::ptr_eq(a, b) || id != next, (None, None) => false, _ => true, }; if changed { app.global::() .invoke_command("close".into(), "".into()); *observed.borrow_mut() = current; } }, ); let (sender, mut receiver) = tokio::sync::mpsc::channel::<( u64, i32, Arc, Result, String>, )>(2); let (thumbnail_sender, mut thumbnail_receiver) = tokio::sync::mpsc::channel::<( i32, i64, Arc, Result<(Vec, u32, u32), String>, )>(8); let (thumbnail_job_sender, mut thumbnail_job_receiver) = tokio::sync::mpsc::channel::<( i32, flectar_mail_core::models::AttachmentMeta, Arc, )>(8); let thumbnail_window = weak.clone(); runtime.spawn(async move { while let Some((message, attachment, core)) = thumbnail_job_receiver.recv().await { let attachment_id = attachment.id; let result = async { let path = core .get_attachment(attachment_id) .await .map_err(|error| error.to_string())?; let file = tokio::fs::File::open(path) .await .map_err(|error| error.to_string())?; let bytes = read_preview_bytes(file, 4 * 1024 * 1024 + 1).await?; image_thumbnail(bytes).await } .await; if thumbnail_sender .send((message, attachment_id, core, result)) .await .is_err() { break; } let _ = thumbnail_window .upgrade_in_event_loop(|app| app.global::().invoke_deliver()); } }); let thumbnail_pending = Rc::new(RefCell::new(HashSet::<(usize, i32, i64)>::new())); let state = inbox.clone(); let session_for_delivery = session.clone(); let current = generation.clone(); let pending_for_delivery = thumbnail_pending.clone(); let window = weak.clone(); app.global::().on_deliver(move || { let Some(app) = window.upgrade() else { return; }; let session = &session_for_delivery; while let Ok((revision, message, core, result)) = receiver.try_recv() { if current.load(Ordering::SeqCst) != revision || app.global::().get_message_id() != message { continue; } if !state .borrow() .core .as_ref() .is_some_and(|c| Arc::ptr_eq(&core, &c.file_core())) { continue; } let ui = app.global::(); ui.set_busy(false); match result { Ok(Some(Preview::Pdf(data, page))) => { session.borrow_mut().document = Some(data); ui.set_page(page.index as i32); ui.set_pages(page.count as i32); ui.set_image(slint::Image::from_rgba8( SharedPixelBuffer::::clone_from_slice( &page.pixels, page.width, page.height, ), )); ui.set_is_image(true); } Ok(Some(Preview::Image(data, w, h))) => { ui.set_image(slint::Image::from_rgba8( SharedPixelBuffer::::clone_from_slice(&data, w, h), )); ui.set_is_image(true); } Ok(Some(Preview::Text(text))) => ui.set_text(text.into()), Ok(None) => close(&ui, ¤t, session), Err(error) => ui.set_status(error.into()), } } while let Ok((message, attachment_id, core, result)) = thumbnail_receiver.try_recv() { let core_id = Arc::as_ptr(&core) as usize; pending_for_delivery .borrow_mut() .remove(&(core_id, message, attachment_id)); if app.global::().get_message_id() != message || !state .borrow() .core .as_ref() .is_some_and(|candidate| Arc::ptr_eq(&core, &candidate.file_core())) { continue; } let Ok((pixels, width, height)) = result else { continue; }; let rows = app.global::().get_rows(); let attachment_id = attachment_id.to_string(); for index in 0..rows.row_count() { let Some(mut row) = rows.row_data(index) else { continue; }; if row.id.as_str() != attachment_id { continue; } row.thumbnail = slint::Image::from_rgba8( SharedPixelBuffer::::clone_from_slice(&pixels, width, height), ); row.has_thumbnail = true; rows.set_row_data(index, row); break; } } }); let state = inbox.clone(); let handle = runtime.clone(); let pending_thumbnails = thumbnail_pending.clone(); app.global::() .on_command(move |action, value| { let _keep_timer = &timer; let Some(app) = weak.upgrade() else { return; }; let ui = app.global::(); if action == "close" { close(&ui, &generation, &session); return; } let Some(core) = state.borrow().core.as_ref().map(|core| core.file_core()) else { return; }; let message = app.global::().get_message_id(); // A profile switch may happen before the lifecycle timer's next // tick. Never use a retained attachment ID with a different Core. if (action == "page" || action == "download-current") && !context .borrow() .as_ref() .is_some_and(|(owner, id)| Arc::ptr_eq(owner, &core) && *id == message) { close(&ui, &generation, &session); return; } // A message-list chip offers the first file of a conversation // that may not be open, so its file comes from the list rows. let listed = matches!(action.as_str(), "preview-listed" | "download-listed"); let selected = if action == "page" || action == "download-current" { session.borrow().selected.clone() } else { value.parse::().ok().and_then(|id| { let state = state.borrow(); if listed { state .messages .iter() .filter_map(|m| m.first_attachment.as_ref()) .find(|a| a.id == id) .cloned() } else { state .conversation_messages .iter() .chain(state.messages.iter()) .find(|m| m.id == message) .and_then(|m| m.attachments.iter().find(|a| a.id == id)) .cloned() } }) }; let Some(selected) = selected else { return; }; let action = match action.as_str() { "preview-listed" => "preview".into(), "download-listed" => "download".into(), _ => action, }; if action == "thumbnail" { let core_id = Arc::as_ptr(&core) as usize; if !thumbnail_candidate(&selected) || !pending_thumbnails .borrow_mut() .insert((core_id, message, selected.id)) { return; } if thumbnail_job_sender .try_send((message, selected.clone(), core)) .is_err() { pending_thumbnails .borrow_mut() .remove(&(core_id, message, selected.id)); } return; } if ui.get_busy() { return; } let page = value.parse::().unwrap_or(0); if action == "page" && page >= ui.get_pages().max(0) as u32 { return; } if !matches!( action.as_str(), "preview" | "download" | "download-current" | "page" ) { return; } let revision = generation.fetch_add(1, Ordering::SeqCst) + 1; let data = session.borrow().document.clone(); session.borrow_mut().selected = Some(selected.clone()); *context.borrow_mut() = Some((core.clone(), message)); if action != "page" { ui.set_name( selected .filename .clone() .unwrap_or_else(|| "attachment".into()) .into(), ); ui.set_pages(0); ui.set_image(Default::default()); ui.set_text("".into()); ui.set_is_image(false); ui.set_revision(ui.get_revision().wrapping_add(1)); session.borrow_mut().document = None; } ui.set_open(true); ui.set_busy(true); ui.set_status("".into()); let weak = weak.clone(); let sender = sender.clone(); let documents = documents.clone(); let task = handle.spawn(async move { let result: Result, String> = async { if action == "page" { let data = data.ok_or("PDF preview has been closed")?; return crate::pdf_preview::render(data.clone(), page, 200) .await .map(|page| Some(Preview::Pdf(data, page))); } let download = action == "download" || action == "download-current"; if !download && selected.size.is_some_and(|size| size > 16 * 1024 * 1024) { return Err( "This attachment is too large to preview. Download it to view locally." .into(), ); } let path = core .get_attachment(selected.id) .await .map_err(|e| e.to_string())?; if download { let dialog = crate::documents::download_dialog_for(&core).await; documents .export_with( path.into(), crate::documents::safe_name( selected.filename.as_deref().unwrap_or("attachment"), ), dialog, ) .await?; return Ok(None); } let file = tokio::fs::File::open(path) .await .map_err(|e| e.to_string())?; let bytes = read_preview_bytes(file, 16 * 1024 * 1024 + 1).await?; if bytes.len() > 16 * 1024 * 1024 { return Err( "This attachment is too large to preview. Download it to view locally." .into(), ); } preview( bytes, selected .mime_type .as_deref() .unwrap_or("application/octet-stream"), ) .await .map(Some) } .await; if sender.send((revision, message, core, result)).await.is_ok() { let _ = weak.upgrade_in_event_loop(|app| { app.global::().invoke_deliver() }); } }); session.borrow_mut().task = Some(task.abort_handle()); }); } #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn preview_reads_bound_capacity_and_keep_the_over_limit_sentinel() { let directory = tempfile::tempdir().unwrap(); let path = directory.path().join("attachment.bin"); let source = (0..200_000) .map(|index| (index % 251) as u8) .collect::>(); tokio::fs::write(&path, &source).await.unwrap(); for limit in [0, 1, 8191, 8192, 8193, 65_537, 200_001] { let bytes = read_preview_bytes(tokio::fs::File::open(&path).await.unwrap(), limit) .await .unwrap(); assert_eq!(bytes, source[..source.len().min(limit)]); assert!(bytes.capacity() <= limit); } } #[test] fn thumbnail_plan_keeps_visible_order_and_four_item_limit() { use flectar_mail_core::models::AttachmentMeta; let files = (0..10) .map(|id| AttachmentMeta { id, filename: (id != 0).then(|| "photo.png".into()), mime_type: Some("image/png".into()), size: Some(42), is_inline: id == 0, }) .collect::>(); let mut rows = attachment_rows(&files); rows[0].has_thumbnail = true; let ids = thumbnail_ids(&files, &rows); assert_eq!( ids.iter().map(|id| id.as_str()).collect::>(), ["2", "3", "4", "5"] ); assert_eq!(ids[0].as_ptr(), rows[1].id.as_ptr()); } #[test] fn file_kinds_follow_the_type_then_the_name() { for (name, media, kind) in [ ("Joel-Solano-CV.pdf", "application/octet-stream", "pdf"), ("scan", "application/pdf", "pdf"), ("photo.HEIC", "", "image"), ( "budget.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "spreadsheet", ), ("totals.csv", "text/csv", "spreadsheet"), ("deck.key", "", "presentation"), ("letter.docx", "", "document"), ("notes.txt", "text/plain", "document"), ("book.epub", "application/epub+zip", "document"), ("site.zip", "application/zip", "archive"), ("invite.ics", "text/calendar", "calendar"), ("clip.mov", "video/quicktime", "video"), ("memo.m4a", "", "audio"), ("config.json", "application/json", "code"), ("data.bin", "application/octet-stream", "file"), ] { assert_eq!(file_kind(name, media), kind, "{name} ({media})"); } } #[test] fn list_chips_keep_the_extension_of_a_cut_name() { assert_eq!( split_extension("Joel-Solano-CV.pdf"), ("Joel-Solano-CV", ".pdf") ); assert_eq!(split_extension("archive.tar.gz"), ("archive.tar", ".gz")); assert_eq!(split_extension("photo.jpeg"), ("photo", ".jpeg")); assert_eq!(split_extension(".bashrc"), (".bashrc", "")); assert_eq!(split_extension("README"), ("README", "")); assert_eq!( split_extension("version 1.2 final"), ("version 1.2 final", "") ); let chip = list_attachment(&flectar_mail_core::models::AttachmentMeta { id: 7, filename: Some("portfolio.zip".into()), mime_type: Some("application/zip".into()), size: Some(9000), is_inline: false, }); assert_eq!(chip.id, "7"); assert_eq!(chip.stem, "portfolio"); assert_eq!(chip.extension, ".zip"); assert_eq!(chip.kind, "archive"); assert!(!chip.previewable); } #[test] fn attachment_rows_preserve_large_ids_and_offer_safe_previews() { use flectar_mail_core::models::AttachmentMeta; let files = vec![ AttachmentMeta { id: i64::MAX, filename: Some("Report.PDF".into()), mime_type: None, size: Some(1024), is_inline: false, }, AttachmentMeta { id: 2, filename: Some("photo.png".into()), mime_type: Some("image/png".into()), size: Some(42), is_inline: false, }, AttachmentMeta { id: 3, filename: None, mime_type: Some("image/png".into()), size: Some(42), is_inline: true, }, AttachmentMeta { id: 4, filename: Some("archive.zip".into()), mime_type: Some("application/zip".into()), size: Some(42), is_inline: false, }, ]; let rows = attachment_rows(&files); assert_eq!(rows.len(), 3); assert_eq!(rows[0].id.as_str(), i64::MAX.to_string()); assert!(rows[0].previewable && rows[1].previewable); assert!(!rows[2].previewable); assert_eq!(rows[0].kind, "pdf"); assert_eq!(rows[1].kind, "image"); assert_eq!(rows[2].kind, "archive"); assert!(!thumbnail_candidate(&files[0])); assert!(thumbnail_candidate(&files[1])); assert!(!rows[1].has_thumbnail); } }