pub(crate) mod engine;
pub(crate) mod report;
use std::io::Write;
use std::path::PathBuf;
use anyhow::{Context, Result};
use clap::Parser;
use serde::Serialize;
use crate::cli::gmail::format::{output_as, write_scalar_jsonl, JsonlSerialize, OutputFormat};
use engine::ExtractAttachmentsOptions;
use report::{ExtractAction, ExtractAttachmentsReport, ExtractError, ExtractSummary};
#[derive(Parser)]
pub struct ExtractAttachmentsCommand {
#[arg(long, value_name = "PATH")]
pub archive_dir: PathBuf,
#[arg(long)]
pub dry_run: bool,
#[arg(long)]
pub quiet: bool,
#[arg(short = 'o', long, value_enum, default_value_t = OutputFormat::Table)]
pub output: OutputFormat,
}
impl ExtractAttachmentsCommand {
pub fn execute(self) -> Result<()> {
run_extract_attachments_command(
ExtractAttachmentsOptions {
archive_dir: self.archive_dir,
dry_run: self.dry_run,
},
self.quiet,
&self.output,
)
}
}
fn run_extract_attachments_command(
opts: ExtractAttachmentsOptions,
quiet: bool,
output: &OutputFormat,
) -> Result<()> {
let report = engine::run_extract_attachments(&opts)?;
let output_view = ExtractAttachmentsReportOutput {
actions: &report.actions,
errors: &report.errors,
summary: report.summary(),
};
if !output_as(&output_view, output)? {
let stdout = std::io::stdout();
let mut handle = stdout.lock();
render_report_text(&report, &mut handle, !quiet)?;
}
if !report.errors.is_empty() {
anyhow::bail!(
"{} message(s) failed to extract; see errors above",
report.errors.len()
);
}
Ok(())
}
#[derive(Serialize)]
struct ExtractAttachmentsReportOutput<'a> {
actions: &'a [ExtractAction],
errors: &'a [ExtractError],
summary: ExtractSummary,
}
impl JsonlSerialize for ExtractAttachmentsReportOutput<'_> {
fn write_jsonl(&self, out: &mut dyn Write) -> Result<()> {
write_scalar_jsonl(self, out)
}
}
fn render_report_text(
report: &ExtractAttachmentsReport,
out: &mut dyn Write,
show_action_detail: bool,
) -> Result<()> {
if report.actions.is_empty() && report.errors.is_empty() {
writeln!(out, "Nothing to do.").context("Failed to write extract-attachments report")?;
return Ok(());
}
if show_action_detail {
for action in &report.actions {
let line = match action {
ExtractAction::Extracted { id, count } => {
format!("Extracted {count} attachment(s) from {id}")
}
ExtractAction::WouldExtract { id, count } => {
format!("Would extract {count} attachment(s) from {id}")
}
};
writeln!(out, "{line}").context("Failed to write extract-attachments report")?;
}
}
for error in &report.errors {
writeln!(out, "Error: {} failed: {}", error.id, error.reason)
.context("Failed to write extract-attachments report")?;
}
writeln!(out, "{}", format_summary_line(&report.summary()))
.context("Failed to write extract-attachments report")?;
Ok(())
}
fn format_summary_line(summary: &ExtractSummary) -> String {
let mut parts = Vec::new();
if summary.extracted > 0 {
parts.push(format!("{} extracted", summary.extracted));
}
if summary.would_extract > 0 {
parts.push(format!("{} would extract", summary.would_extract));
}
parts.push(format!("{} errors", summary.errors));
parts.join(", ")
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
fn write_manifest_with_attachment(archive_dir: &std::path::Path) {
use base64::Engine as _;
use crate::cli::gmail::sync::manifest::{Manifest, ManifestRecord};
use crate::cli::gmail::sync::shard::shard_path;
let internal_date = chrono::DateTime::from_timestamp_millis(1_700_000_000_000).unwrap();
let encoded_attachment = base64::engine::general_purpose::STANDARD.encode(b"PDF-CONTENT");
let raw = format!(
"Subject: Report\r\n\
From: a@example.com\r\n\
MIME-Version: 1.0\r\n\
Content-Type: multipart/mixed; boundary=\"BOUNDARY\"\r\n\
\r\n\
--BOUNDARY\r\n\
Content-Type: application/pdf\r\n\
Content-Transfer-Encoding: base64\r\n\
Content-Disposition: attachment; filename=\"report.pdf\"\r\n\
\r\n\
{encoded_attachment}\r\n\
--BOUNDARY--\r\n"
);
let path = shard_path(archive_dir, "m1", Some(internal_date));
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, &raw).unwrap();
let relative = path.strip_prefix(archive_dir).unwrap().to_path_buf();
let mut manifest = Manifest::default();
manifest.upsert(ManifestRecord {
id: "m1".to_string(),
thread_id: None,
label_ids: Vec::new(),
internal_date: Some("1700000000000".to_string()),
subject: None,
from: None,
to: None,
rfc822_msgid: None,
in_reply_to: None,
references: None,
attachment_count: 1,
attachment_filenames: Vec::new(),
path: relative,
size: raw.len() as u64,
history_id: None,
deleted_at: None,
});
manifest
.save(&crate::cli::gmail::sync::engine::manifest_path(archive_dir))
.unwrap();
}
#[test]
fn run_extract_attachments_command_writes_attachment_files() {
let dir = tempfile::tempdir().unwrap();
let archive_dir = dir.path().join("archive");
write_manifest_with_attachment(&archive_dir);
run_extract_attachments_command(
ExtractAttachmentsOptions {
archive_dir: archive_dir.clone(),
dry_run: false,
},
true,
&OutputFormat::Table,
)
.unwrap();
let internal_date = chrono::DateTime::from_timestamp_millis(1_700_000_000_000).unwrap();
let written = crate::cli::gmail::sync::shard::attachments_dir(
&archive_dir,
"m1",
Some(internal_date),
)
.join("report.pdf");
assert_eq!(std::fs::read(written).unwrap(), b"PDF-CONTENT");
}
#[test]
fn run_extract_attachments_command_dry_run_touches_no_files() {
let dir = tempfile::tempdir().unwrap();
let archive_dir = dir.path().join("archive");
write_manifest_with_attachment(&archive_dir);
run_extract_attachments_command(
ExtractAttachmentsOptions {
archive_dir: archive_dir.clone(),
dry_run: true,
},
true,
&OutputFormat::Table,
)
.unwrap();
let internal_date = chrono::DateTime::from_timestamp_millis(1_700_000_000_000).unwrap();
assert!(!crate::cli::gmail::sync::shard::attachments_dir(
&archive_dir,
"m1",
Some(internal_date)
)
.exists());
}
#[test]
fn run_extract_attachments_command_surfaces_a_non_zero_exit_on_errors() {
use crate::cli::gmail::sync::manifest::{Manifest, ManifestRecord};
let dir = tempfile::tempdir().unwrap();
let archive_dir = dir.path().join("archive");
std::fs::create_dir_all(&archive_dir).unwrap();
let mut manifest = Manifest::default();
manifest.upsert(ManifestRecord {
id: "m1".to_string(),
thread_id: None,
label_ids: Vec::new(),
internal_date: Some("1700000000000".to_string()),
subject: None,
from: None,
to: None,
rfc822_msgid: None,
in_reply_to: None,
references: None,
attachment_count: 1,
attachment_filenames: Vec::new(),
path: PathBuf::from("messages/missing/m1.eml"),
size: 0,
history_id: None,
deleted_at: None,
});
manifest
.save(&crate::cli::gmail::sync::engine::manifest_path(
&archive_dir,
))
.unwrap();
let err = run_extract_attachments_command(
ExtractAttachmentsOptions {
archive_dir,
dry_run: false,
},
true,
&OutputFormat::Table,
)
.unwrap_err();
assert!(err.to_string().contains("1 message(s) failed to extract"));
}
#[test]
fn run_extract_attachments_command_renders_json_summary() {
let dir = tempfile::tempdir().unwrap();
let archive_dir = dir.path().join("archive");
write_manifest_with_attachment(&archive_dir);
run_extract_attachments_command(
ExtractAttachmentsOptions {
archive_dir,
dry_run: false,
},
true,
&OutputFormat::Json,
)
.unwrap();
}
#[test]
fn empty_archive_reports_nothing_to_do() {
let dir = tempfile::tempdir().unwrap();
let archive_dir = dir.path().join("archive");
std::fs::create_dir_all(&archive_dir).unwrap();
run_extract_attachments_command(
ExtractAttachmentsOptions {
archive_dir,
dry_run: false,
},
false,
&OutputFormat::Table,
)
.unwrap();
}
}