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()
.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,
}
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>,
#[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
}