use std::{
collections::BTreeSet,
fs,
path::{
Path,
PathBuf,
},
};
use chrono::{
DateTime,
Utc,
};
use clap::Parser;
use color_eyre::eyre::{
Context,
ContextCompat,
Result,
};
use indicatif::{
ProgressBar,
ProgressStyle,
};
use is_terminal::IsTerminal;
use jmap_client::{
client::Client,
core::query::Filter,
email::{
self,
Property,
},
};
use tokio::{
fs::create_dir_all,
io::AsyncWriteExt,
};
use tracing::info;
#[derive(Debug, Parser)]
#[command(version, about, long_about = None)]
#[command(propagate_version = true)]
pub(crate) struct Cli {
#[arg(short, long, value_name = "PATH", default_value = "emails")]
backup_folder: PathBuf,
#[arg(short, long, env = "JMAP_TOKEN", value_name = "TOKEN")]
token: String,
#[arg(
long,
env = "JMAP_URL",
value_name = "URL",
default_value = "https://api.fastmail.com"
)]
url: String,
#[arg(
long,
env = "JMAP_REDIRECT_HOST",
value_name = "HOST",
default_value = "api.fastmail.com"
)]
redirect_host: Vec<String>,
}
#[tokio::main]
#[fastrace::trace]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "info".into()),
)
.init();
let cli = Cli::parse();
info!("starting");
info!("creating jmap client");
let client = Client::new()
.credentials(cli.token)
.follow_redirects(cli.redirect_host)
.connect(&cli.url)
.await
.context("failed to create jmap client")?;
let filter = Filter::and([email::query::Filter::Before { value: Utc::now() }]).into();
let sort = [email::query::Comparator::received_at()].into();
info!("getting emails");
let mut emails = client
.email_query(filter, sort)
.await
.context("failed to query emails")?;
let emails = emails.take_ids();
info!("check against existing emails");
let existing_emails = list_files_recursive(&cli.backup_folder)?;
let emails = emails
.into_iter()
.filter(|id| !existing_emails.contains(&format!("{id}.eml")))
.collect::<Vec<_>>();
info!("fetch emails");
let is_terminal = std::io::stdout().is_terminal();
let emails_count = emails.len();
let bar = if is_terminal {
println!();
ProgressBar::new(emails_count as u64)
} else {
ProgressBar::hidden()
};
bar.set_style(
ProgressStyle::with_template(
"[{elapsed_precise}] {bar:40.cyan/blue} {pos:>7}/{len:7} {msg}",
)
.context("failed to set progress style")?
.progress_chars("##-"),
);
for email in emails {
bar.set_message(email.clone());
if !is_terminal {
info!("fetching email {email} [{}/{emails_count}]", bar.position());
}
fetch_email(&client, &cli.backup_folder, &email)
.await
.context("failed to fetch email")?;
bar.inc(1);
}
bar.finish();
if is_terminal {
println!();
}
info!("finished");
Ok(())
}
#[fastrace::trace]
async fn fetch_email<P: AsRef<Path>>(client: &Client, backup_path: P, id: &str) -> Result<()> {
let properties = [Property::BlobId, Property::ReceivedAt].into();
let email = client
.email_get(id, properties)
.await
.context("failed to get email")?
.context("email not found")?;
let id = email.id().context("missing id")?;
let blob_id = email.blob_id().context("missing blob_id")?;
let received_at = email.received_at().context("missing received_at")?;
let received_at =
DateTime::from_timestamp(received_at, 0).context("failed to parse received_at")?;
let blob = client
.download(blob_id)
.await
.context("failed to download blob")?;
create_dir_all(
backup_path
.as_ref()
.join(format!("{}", received_at.format("%Y/%m/%d"))),
)
.await
.context("failed to create dir")?;
let mail_path = backup_path
.as_ref()
.join(format!("{}/{id}.eml", received_at.format("%Y/%m/%d")));
if mail_path.exists() {
return Ok(());
}
let file = tokio::fs::File::create(mail_path)
.await
.context("failed to create file")?;
let mut writer = tokio::io::BufWriter::new(file);
writer
.write_all(&blob)
.await
.context("failed to write blob")?;
writer.flush().await.context("failed to flush writer")?;
Ok(())
}
#[fastrace::trace]
fn list_files_recursive<P: AsRef<Path>>(dir: P) -> std::io::Result<BTreeSet<String>> {
let mut filenames = BTreeSet::new();
if dir.as_ref().is_dir() {
for entry_result in fs::read_dir(dir)? {
let entry = entry_result?;
let path = entry.path();
if path.is_dir() {
let mut sub_filenames = list_files_recursive(&path)?;
filenames.append(&mut sub_filenames);
} else {
if let Some(name) = path.file_name() {
filenames.insert(name.to_string_lossy().into_owned());
}
}
}
}
Ok(filenames)
}