Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,534 changes: 0 additions & 1,534 deletions src/attachments.rs

This file was deleted.

57 changes: 57 additions & 0 deletions src/attachments/error.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
use crate::store;
use std::path::PathBuf;
use thiserror::Error;

#[derive(Debug, Error)]
pub enum AttachmentServiceError {
#[error("no active Gmail account found; run `mailroom auth login` first")]
NoActiveAccount,
#[error("attachment `{attachment_key}` was not found in the local mailbox catalog")]
AttachmentNotFound { attachment_key: String },
#[error("attachment list limit must be greater than zero")]
InvalidLimit,
#[error("attachment vault path `{relative_path}` is invalid")]
InvalidVaultPath { relative_path: String },
#[error("export destination already exists with different content: {path}")]
DestinationConflict { path: PathBuf },
#[error("failed to join blocking attachment task: {source}")]
BlockingTask {
#[source]
source: tokio::task::JoinError,
},
#[error("failed to create directory {path}: {source}")]
CreateDirectory {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to write file {path}: {source}")]
WriteFile {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to read file {path}: {source}")]
ReadFile {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to copy file from {source_path} to {destination_path}: {source}")]
CopyFile {
source_path: PathBuf,
destination_path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to persist attachment store state: {source}")]
StoreWrite {
#[source]
source: anyhow::Error,
},
#[error("failed to read attachment state from local mailbox store: {source}")]
StoreRead {
#[source]
source: store::mailbox::MailboxReadError,
},
}
163 changes: 163 additions & 0 deletions src/attachments/export.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
use super::error::AttachmentServiceError;
use super::vault::hash_file_blake3;
use crate::workspace::WorkspacePaths;
use anyhow::Result;
use sanitize_filename::sanitize;
use std::fs;
use std::path::{Path, PathBuf};
use tokio::task::spawn_blocking;

pub(super) fn export_filename(filename: &str, attachment_key: &str) -> String {
let trimmed = filename.trim();
if !trimmed.is_empty() {
let sanitized = sanitize(trimmed).to_string();
if !sanitized.trim().is_empty() {
return sanitized;
}
}
format!(
"attachment-{}.bin",
sanitize_non_empty(attachment_key, "attachment")
)
}

pub(super) fn default_export_path(
workspace_paths: &WorkspacePaths,
thread_id: &str,
message_id: &str,
attachment_key: &str,
filename: &str,
) -> PathBuf {
workspace_paths
.exports_dir
.join(sanitize_non_empty(thread_id, "thread"))
.join(format!(
"{}--{}--{}",
sanitize_non_empty(message_id, "message"),
sanitize_non_empty(attachment_key, "attachment"),
filename
))
}

pub(super) fn resolve_export_destination_path(
workspace_paths: &WorkspacePaths,
thread_id: &str,
message_id: &str,
attachment_key: &str,
filename: &str,
destination: Option<PathBuf>,
) -> Result<PathBuf> {
Ok(match destination {
Some(path) if path.is_dir() => path.join(filename),
Some(path) => path,
None => default_export_path(
workspace_paths,
thread_id,
message_id,
attachment_key,
filename,
),
})
}

fn sanitize_non_empty(value: &str, fallback: &str) -> String {
let sanitized = sanitize(value).to_string();
if sanitized.trim().is_empty() {
return fallback.to_owned();
}
sanitized
}

#[derive(Debug)]
pub(super) struct CopyFromVaultResult {
pub(super) copied: bool,
}

pub(super) fn copy_from_vault(
source_path: &Path,
destination_path: &Path,
content_hash: &str,
) -> Result<CopyFromVaultResult> {
let parent =
destination_path
.parent()
.ok_or_else(|| AttachmentServiceError::CreateDirectory {
path: destination_path.to_path_buf(),
source: std::io::Error::other("destination path has no parent"),
})?;
fs::create_dir_all(parent).map_err(|source| AttachmentServiceError::CreateDirectory {
path: parent.to_path_buf(),
source,
})?;

let mut source_file =
fs::File::open(source_path).map_err(|source| AttachmentServiceError::CopyFile {
source_path: source_path.to_path_buf(),
destination_path: destination_path.to_path_buf(),
source,
})?;
let mut destination_file = match fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(destination_path)
{
Ok(file) => file,
Err(source) if source.kind() == std::io::ErrorKind::AlreadyExists => {
let existing_hash = hash_file_blake3(destination_path)?;
if existing_hash == content_hash {
return Ok(CopyFromVaultResult { copied: false });
}
return Err(AttachmentServiceError::DestinationConflict {
path: destination_path.to_path_buf(),
}
.into());
}
Err(source) => {
return Err(AttachmentServiceError::CopyFile {
source_path: source_path.to_path_buf(),
destination_path: destination_path.to_path_buf(),
source,
}
.into());
}
};
let write_result = (|| -> Result<()> {
std::io::copy(&mut source_file, &mut destination_file).map_err(|source| {
AttachmentServiceError::CopyFile {
source_path: source_path.to_path_buf(),
destination_path: destination_path.to_path_buf(),
source,
}
})?;
destination_file
.sync_all()
.map_err(|source| AttachmentServiceError::CopyFile {
source_path: source_path.to_path_buf(),
destination_path: destination_path.to_path_buf(),
source,
})?;
Ok(())
})();
if write_result.is_err() {
let _ = fs::remove_file(destination_path);
}
write_result?;
Ok(CopyFromVaultResult { copied: true })
}

pub(super) async fn cleanup_export_file_task(destination_path: PathBuf) {
let cleanup_path = destination_path.clone();
let join_result = spawn_blocking(move || fs::remove_file(&cleanup_path)).await;
match join_result {
Ok(Ok(())) => {}
Ok(Err(error)) if error.kind() == std::io::ErrorKind::NotFound => {}
Ok(Err(error)) => eprintln!(
"warning: failed to remove exported attachment after persistence failure: {} ({error})",
destination_path.display()
),
Err(error) => eprintln!(
"warning: failed to join export cleanup task for {}: {error}",
destination_path.display()
),
}
}
26 changes: 26 additions & 0 deletions src/attachments/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
mod error;
mod export;
mod reports;
mod service;
mod vault;

#[cfg(test)]
mod tests;

pub use error::AttachmentServiceError;
pub use reports::{
AttachmentExportReport, AttachmentFetchReport, AttachmentListReport, AttachmentShowReport,
};
pub use service::{export, fetch, list, show};

pub const DEFAULT_ATTACHMENT_LIST_LIMIT: usize = 50;

#[derive(Debug, Clone)]
pub struct AttachmentListRequest {
pub thread_id: Option<String>,
pub message_id: Option<String>,
pub filename: Option<String>,
pub mime_type: Option<String>,
pub fetched_only: bool,
pub limit: usize,
}
138 changes: 138 additions & 0 deletions src/attachments/reports.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
use crate::store;
use anyhow::Result;
use serde::Serialize;
use std::path::PathBuf;

#[derive(Debug, Clone, Serialize)]
pub struct AttachmentListReport {
pub account_id: String,
pub thread_id: Option<String>,
pub message_id: Option<String>,
pub filename: Option<String>,
pub mime_type: Option<String>,
pub fetched_only: bool,
pub limit: usize,
pub items: Vec<store::mailbox::AttachmentListItem>,
}

#[derive(Debug, Clone, Serialize)]
pub struct AttachmentShowReport {
pub account_id: String,
pub attachment: store::mailbox::AttachmentDetailRecord,
}

#[derive(Debug, Clone, Serialize)]
pub struct AttachmentFetchReport {
pub account_id: String,
pub attachment_key: String,
pub message_id: String,
pub thread_id: String,
pub filename: String,
pub mime_type: String,
pub size_bytes: i64,
pub content_hash: String,
pub vault_relative_path: String,
pub vault_path: PathBuf,
pub downloaded: bool,
pub fetched_at_epoch_s: i64,
}

#[derive(Debug, Clone, Serialize)]
pub struct AttachmentExportReport {
pub account_id: String,
pub attachment_key: String,
pub message_id: String,
pub thread_id: String,
pub filename: String,
pub content_hash: String,
pub source_vault_path: PathBuf,
pub destination_path: PathBuf,
pub copied: bool,
pub exported_at_epoch_s: i64,
}

impl AttachmentListReport {
pub fn print(&self, json: bool) -> Result<()> {
if json {
crate::cli_output::print_json_success(self)?;
} else {
println!("account_id={}", self.account_id);
println!("items={}", self.items.len());
for item in &self.items {
println!(
"{}\t{}\t{}\tfetched={}\texports={}",
item.attachment_key,
item.filename,
item.mime_type,
item.vault_relative_path.is_some(),
item.export_count,
);
}
}
Ok(())
}
}

impl AttachmentShowReport {
pub fn print(&self, json: bool) -> Result<()> {
if json {
crate::cli_output::print_json_success(self)?;
} else {
let attachment = &self.attachment;
println!("account_id={}", self.account_id);
println!("attachment_key={}", attachment.attachment_key);
println!("message_id={}", attachment.message_id);
println!("thread_id={}", attachment.thread_id);
println!("filename={}", attachment.filename);
println!("mime_type={}", attachment.mime_type);
println!("size_bytes={}", attachment.size_bytes);
println!("fetched={}", attachment.vault_relative_path.is_some());
println!("export_count={}", attachment.export_count);
match &attachment.vault_relative_path {
Some(path) => println!("vault_relative_path={path}"),
None => println!("vault_relative_path=<none>"),
}
}
Ok(())
}
}

impl AttachmentFetchReport {
pub fn print(&self, json: bool) -> Result<()> {
if json {
crate::cli_output::print_json_success(self)?;
} else {
println!("account_id={}", self.account_id);
println!("attachment_key={}", self.attachment_key);
println!("message_id={}", self.message_id);
println!("thread_id={}", self.thread_id);
println!("filename={}", self.filename);
println!("mime_type={}", self.mime_type);
println!("size_bytes={}", self.size_bytes);
println!("content_hash={}", self.content_hash);
println!("downloaded={}", self.downloaded);
println!("vault_relative_path={}", self.vault_relative_path);
println!("vault_path={}", self.vault_path.display());
println!("fetched_at_epoch_s={}", self.fetched_at_epoch_s);
}
Ok(())
}
}

impl AttachmentExportReport {
pub fn print(&self, json: bool) -> Result<()> {
if json {
crate::cli_output::print_json_success(self)?;
} else {
println!("account_id={}", self.account_id);
println!("attachment_key={}", self.attachment_key);
println!("filename={}", self.filename);
println!("content_hash={}", self.content_hash);
println!("copied={}", self.copied);
println!("source_vault_path={}", self.source_vault_path.display());
println!("destination_path={}", self.destination_path.display());
println!("exported_at_epoch_s={}", self.exported_at_epoch_s);
}
Ok(())
}
}
Loading