ddgm 0.1.0

ddgm: DuckDuckGo eMail
mod auth;
mod email_address;

use auth::sign_in;
use email_address::{DuckAddress, EmailAddress, parse_duck_address, parse_email_address};

use std::fmt;
use std::fs;
use std::io::{self, Read, Write};
use std::path;
use std::process::ExitCode;

use clap::Parser;
use reqwest::{self, StatusCode, Url};
use serde::Deserialize;

const _DDG_API_BASE_URL: &str = "https://quack.duckduckgo.com/api";
const DDG_API_EMAIL_ADDRESS: &str = "https://quack.duckduckgo.com/api/email/addresses";
const DDG_DOMAIN: &str = "duck.com";

fn get_address_file() -> io::Result<fs::File> {
    let project_dirs = match directories::ProjectDirs::from("", "", "ddgm") {
        Some(project_dirs) => project_dirs,
        None => panic!("no valid home directory found"),
    };
    let data_dir = project_dirs.data_dir();
    if !data_dir.exists() {
        fs::DirBuilder::new().create(data_dir)?;
    }

    let address_file = project_dirs.data_dir().join("addresses");
    let file = if !address_file.exists() {
        fs::OpenOptions::new()
            .create_new(true)
            .write(true)
            .open(address_file)?
    } else {
        fs::OpenOptions::new().append(true).open(address_file)?
    };

    Ok(file)
}

fn get_token_file() -> io::Result<path::PathBuf> {
    let project_dirs = match directories::ProjectDirs::from("", "", "ddgm") {
        Some(project_dirs) => project_dirs,
        None => panic!("no valid home directory found"),
    };

    let config_dir = project_dirs.config_dir();
    if !config_dir.exists() {
        fs::DirBuilder::new().create(config_dir)?;
    }
    let token_file_path = project_dirs.data_dir().join("token");
    Ok(token_file_path)
}

fn get_client() -> reqwest::Client {
    reqwest::ClientBuilder::new()
        // TODO: try to get rid of this user agent.
        //
        // This was added because of an observed behaviour that ddg assumes you are a bot if you
        // send request without user agent and it requires a captcha to be passed to proceed any
        // further
        .user_agent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36")
        .build()
        .expect("Something went wrong with building reqwest client")
}

#[allow(unused)]
#[derive(Deserialize)]
struct SignInResponse {
    status: String,
    token: String,
    user: String,
}

#[allow(unused)]
#[derive(Deserialize)]
struct Stats {
    addresses_generated: u8,
}

#[allow(unused)]
#[derive(Deserialize)]
struct User {
    access_token: String,
    cohort: String,
    email: String,
    username: String,
}

#[allow(unused)]
#[derive(Deserialize)]
struct SignInDashboardResponse {
    invites: Vec<u8>,
    stats: Stats,
    user: User,
}

type Token = String;

fn get_token() -> io::Result<Token> {
    let file = get_token_file()?;
    if !fs::exists(file.clone())? {
        return Err(io::Error::new(
            io::ErrorKind::NotFound,
            "token file not found",
        ));
    }
    let mut buf = String::new();
    let mut file = fs::OpenOptions::new().read(true).open(file)?;
    file.read_to_string(&mut buf)?;
    Ok(buf)
}

#[derive(Debug)]
enum DdgmError {
    NotSignedIn,
    DataConversion,
    SomethingWentWrong,
}

impl fmt::Display for DdgmError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            DdgmError::NotSignedIn => fmt::Display::fmt("not signed in", f),
            DdgmError::DataConversion => {
                fmt::Display::fmt("unable to convert data recieved from server", f)
            }
            DdgmError::SomethingWentWrong => fmt::Display::fmt("something went wrong", f),
        }
    }
}

#[derive(Deserialize)]
struct GenerateAliasResponse {
    address: String,
}

async fn generate_new_alias() -> Result<DuckAddress, DdgmError> {
    let token = get_token().map_err(|_| DdgmError::NotSignedIn)?;
    let client = get_client();

    let res = client
        .post(DDG_API_EMAIL_ADDRESS)
        .bearer_auth(token)
        .send()
        .await
        .expect("Unable to send post request to generate new alias");
    if res.status() != StatusCode::CREATED {
        return Err(DdgmError::SomethingWentWrong);
    }
    let res = res
        .json::<GenerateAliasResponse>()
        .await
        .map_err(|_| DdgmError::DataConversion)?;

    let alias = format!("{}@{}", res.address, DDG_DOMAIN)
        .try_into()
        .unwrap();
    if save_alias(&alias).is_err() {
        eprintln!(
            "ERROR: failed to save newly generated address, please use `--add-existing-alias='{}'` to store it",
            alias
        );
    }

    Ok(alias)
}

fn save_alias(alias: &DuckAddress) -> io::Result<()> {
    let mut file = get_address_file()?;
    writeln!(file, "{alias}")?;
    Ok(())
}

#[derive(Deserialize)]
struct AliasStatusResponse {
    active: bool,
}

// There is no point in storing the "is active" part on disk as the user can always deactivate
// any alias by going through the links in their email inbox. This will cause the data to be
// inconsistent and so it is better to query the source of truth.
async fn is_alias_active(address: &DuckAddress) -> Result<bool, DdgmError> {
    let token = get_token().map_err(|_| DdgmError::NotSignedIn)?;
    let client = get_client();

    let url =
        Url::parse_with_params(DDG_API_EMAIL_ADDRESS, &[("address", address.get_alias())]).unwrap();
    let res = client
        .get(url)
        .bearer_auth(token)
        .send()
        .await
        .expect("Unable to check status of alias");
    if res.status() != StatusCode::OK {
        return Err(DdgmError::SomethingWentWrong);
    }
    let res = res
        .json::<AliasStatusResponse>()
        .await
        .map_err(|_| DdgmError::DataConversion)?;

    Ok(res.active)
}

async fn change_alias_status(address: &DuckAddress, active: bool) -> Result<bool, DdgmError> {
    let token = get_token().map_err(|_| DdgmError::NotSignedIn)?;
    let client = get_client();

    let url = Url::parse_with_params(
        DDG_API_EMAIL_ADDRESS,
        &[
            ("address", address.get_alias()),
            ("active", if active { "true" } else { "false" }),
        ],
    )
    .unwrap();
    let res = client
        .put(url)
        .bearer_auth(token)
        .send()
        .await
        .expect("Unable to change status of alias");
    if res.status() != StatusCode::OK {
        return Err(DdgmError::SomethingWentWrong);
    }
    let res = res
        .json::<AliasStatusResponse>()
        .await
        .expect("Failed to convert alias status response to struct");

    Ok(res.active)
}

fn genereate_to_address(to_email: &EmailAddress, from_email: &DuckAddress) -> String {
    format!(
        "{}_at_{}_{}@{}",
        to_email.local_part, to_email.domain, from_email, DDG_DOMAIN
    )
}

#[derive(Parser)]
#[command(version, about)]
struct Cli {
    #[arg(long, value_name = "EMAIL_ADDRESS", value_parser = parse_duck_address)]
    sign_in: Option<DuckAddress>,

    #[arg(long)]
    sign_out: bool,

    #[arg(long)]
    new_alias: bool,

    #[arg(long, value_name = "EMAIL_ADDRESS", value_parser = parse_duck_address)]
    activate: Option<DuckAddress>,

    #[arg(long, value_name = "EMAIL_ADDRESS", value_parser = parse_duck_address)]
    deactivate: Option<DuckAddress>,

    #[arg(long, value_name = "EMAIL_ADDRESS", value_parser = parse_duck_address)]
    add_existing_alias: Option<DuckAddress>,

    // Ref: <https://duckduckgo.com/duckduckgo-help-pages/email-protection/duck-addresses/how-do-i-compose-a-new-email>
    #[arg(long, value_name = "EMAIL_ADDRESS", value_parser = parse_email_address)]
    to: Option<EmailAddress>,
    #[arg(long, value_name = "EMAIL_ADDRESS", value_parser = parse_duck_address)]
    from: Option<DuckAddress>,
}

#[tokio::main]
async fn main() -> ExitCode {
    let cli = Cli::parse();

    if let Some(email) = cli.sign_in {
        let msg = sign_in(&email).await.unwrap();
        println!("{}", msg);
        return ExitCode::SUCCESS;
    }

    if cli.sign_out {
        unimplemented!("sign-out");
    }

    if cli.new_alias {
        return match generate_new_alias().await {
            Ok(alias) => {
                println!("{}", alias);
                ExitCode::SUCCESS
            }
            Err(err) => {
                eprintln!("{}", err);
                ExitCode::FAILURE
            }
        };
    }

    if let Some(email) = cli.activate {
        let active = match is_alias_active(&email).await {
            Ok(active) => active,
            Err(err) => {
                eprintln!("{}", err);
                return ExitCode::FAILURE;
            }
        };
        if active {
            return ExitCode::SUCCESS;
        }

        return match change_alias_status(&email, true).await {
            Ok(_) => ExitCode::SUCCESS,
            Err(err) => {
                eprintln!("{}", err);
                ExitCode::FAILURE
            }
        };
    }
    if let Some(email) = cli.deactivate {
        let active = match is_alias_active(&email).await {
            Ok(active) => active,
            Err(err) => {
                eprintln!("{}", err);
                return ExitCode::FAILURE;
            }
        };
        if !active {
            return ExitCode::SUCCESS;
        }

        return match change_alias_status(&email, false).await {
            Ok(_) => ExitCode::SUCCESS,
            Err(err) => {
                eprintln!("{}", err);
                ExitCode::FAILURE
            }
        };
    }

    if let Some(email) = cli.add_existing_alias {
        return match save_alias(&email) {
            Ok(_) => ExitCode::SUCCESS,
            Err(err) => {
                eprintln!("{}", err);
                ExitCode::FAILURE
            }
        };
    }

    match (cli.to, cli.from) {
        (Some(to_email), Some(from_email)) => {
            let to = genereate_to_address(&to_email, &from_email);
            println!("{}", to);
            return ExitCode::SUCCESS;
        }
        (None, None) => (),
        (_, _) => {
            eprintln!("both `to` and `from` email addresses are required");
            return ExitCode::FAILURE;
        }
    }

    ExitCode::FAILURE
}