jmap-backup-rs 0.1.0

Backup email from a JMAP account to local .eml files
//! Back up email from a JMAP account to local `.eml` files.

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 {
    /// Path to the backup folder
    #[arg(short, long, value_name = "PATH", default_value = "emails")]
    backup_folder: PathBuf,

    /// JMAP bearer token
    #[arg(short, long, env = "JMAP_TOKEN", value_name = "TOKEN")]
    token: String,

    /// JMAP API endpoint
    #[arg(
        long,
        env = "JMAP_URL",
        value_name = "URL",
        default_value = "https://api.fastmail.com"
    )]
    url: String,

    /// Hosts that the JMAP client may follow redirects to
    #[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 this is a directory, iterate over its entries.
    if dir.as_ref().is_dir() {
        for entry_result in fs::read_dir(dir)? {
            let entry = entry_result?;
            let path = entry.path();

            // If it's a directory, recurse.
            if path.is_dir() {
                let mut sub_filenames = list_files_recursive(&path)?;
                filenames.append(&mut sub_filenames);
            } else {
                // Get the file name (ignore the path to it).
                if let Some(name) = path.file_name() {
                    filenames.insert(name.to_string_lossy().into_owned());
                }
            }
        }
    }

    Ok(filenames)
}