Merge pull request #462 from soywod/backend-features

`v1.0.0-beta` ready for testing 🎉
This commit is contained in:
Clément DOUIN 2023-12-12 14:49:44 +01:00 committed by GitHub
commit b623468d15
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
146 changed files with 9133 additions and 5803 deletions

3463
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,7 @@
[package]
name = "himalaya"
description = "CLI to manage emails."
version = "0.9.0"
description = "CLI to manage emails"
version = "1.0.0-beta"
authors = ["soywod <clement.douin@posteo.net>"]
edition = "2021"
license = "MIT"
@ -10,139 +10,71 @@ keywords = ["cli", "mail", "email", "client", "imap"]
homepage = "https://pimalaya.org/himalaya"
documentation = "https://pimalaya.org/himalaya/"
repository = "https://github.com/soywod/himalaya/"
metadata.docs.rs.all-features = true
[package.metadata.docs.rs]
all-features = true
rustdoc-args = ["--cfg", "docsrs"]
[features]
default = [
"imap-backend",
# "notmuch-backend",
"smtp-sender",
"maildir",
"imap",
# "notmuch",
"smtp",
"sendmail",
# "pgp-commands",
# "pgp-gpg",
# "pgp-native",
]
imap-backend = ["email-lib/imap-backend"]
notmuch-backend = ["email-lib/notmuch-backend"]
smtp-sender = ["email-lib/smtp-sender"]
maildir = ["email-lib/maildir"]
imap = ["email-lib/imap"]
notmuch = ["email-lib/notmuch"]
smtp = ["email-lib/smtp"]
sendmail = ["email-lib/sendmail"]
pgp = []
pgp-commands = ["pgp", "email-lib/pgp-commands"]
pgp-gpg = ["pgp", "email-lib/pgp-gpg"]
pgp-native = ["pgp", "email-lib/pgp-native"]
pgp-commands = ["pgp", "mml-lib/pgp-commands", "email-lib/pgp-commands"]
pgp-gpg = ["pgp", "mml-lib/pgp-gpg", "email-lib/pgp-gpg"]
pgp-native = ["pgp", "mml-lib/pgp-native", "email-lib/pgp-native"]
# dev dependencies
[dev-dependencies]
async-trait = "0.1"
tempfile = "3.3"
[dev-dependencies.async-trait]
version = "0.1"
[dev-dependencies.tempfile]
version = "3.3"
# dependencies
[dependencies.anyhow]
version = "1.0"
[dependencies.atty]
version = "0.2"
[dependencies.chrono]
version = "0.4.24"
[dependencies.clap]
version = "4.0"
[dependencies.clap_complete]
version = "4.0"
[dependencies.clap_mangen]
version = "0.2"
[dependencies.console]
version = "0.15.2"
[dependencies.dialoguer]
version = "0.10.2"
[dependencies.dirs]
version = "4.0.0"
[dependencies.email_address]
version = "0.2.4"
[dependencies.env_logger]
version = "0.8"
[dependencies.erased-serde]
version = "0.3"
[dependencies.indicatif]
version = "0.17"
[dependencies.log]
version = "0.4"
[dependencies.md5]
version = "0.7.0"
[dependencies.once_cell]
version = "1.16.0"
[dependencies.email-lib]
version = "=0.15.3"
default-features = false
[dependencies.keyring-lib]
version = "=0.1.0"
[dependencies.oauth-lib]
version = "=0.1.0"
[dependencies.process-lib]
version = "=0.1.0"
[dependencies.mml-lib]
version = "=0.5.0"
default-features = false
[dependencies.secret-lib]
version = "=0.1.0"
[dependencies.serde]
version = "1.0"
features = ["derive"]
[dependencies.serde_json]
version = "1.0"
[dependencies.shellexpand-utils]
version = "=0.1.0"
[dependencies.termcolor]
version = "1.1"
[dependencies.terminal_size]
version = "0.1"
[dependencies.tokio]
version = "1.23"
default-features = false
features = ["macros", "rt-multi-thread"]
[dependencies.toml]
version = "0.7.4"
[dependencies.toml_edit]
version = "0.19.8"
[dependencies.unicode-width]
version = "0.1"
[dependencies.url]
version = "2.2"
[dependencies.uuid]
version = "0.8"
features = ["v4"]
[dependencies]
anyhow = "1"
async-trait = "0.1"
chrono = "0.4.24"
clap = { version = "4.4", features = ["derive"] }
clap_complete = "4.4"
clap_mangen = "0.2"
console = "0.15.2"
dialoguer = "0.10.2"
dirs = "4.0"
email-lib = { version = "=0.17.1", default-features = false }
email_address = "0.2.4"
env_logger = "0.8"
erased-serde = "0.3"
indicatif = "0.17"
keyring-lib = "=0.3.0"
log = "0.4"
mail-builder = "0.3"
md5 = "0.7.0"
mml-lib = { version = "=1.0.3", default-features = false }
oauth-lib = "=0.1.0"
once_cell = "1.16"
process-lib = "=0.3.0"
secret-lib = "=0.3.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
shellexpand-utils = "=0.2.0"
termcolor = "1.1"
terminal_size = "0.1"
tokio = { version = "1.23", default-features = false, features = ["macros", "rt-multi-thread"] }
toml = "0.7.4"
toml_edit = "0.19.8"
unicode-width = "0.1"
url = "2.2"
uuid = { version = "0.8", features = ["v4"] }
[target.'cfg(target_env = "musl")'.dependencies.rusqlite]
version = "0.29"
@ -153,4 +85,4 @@ version = "0.29"
features = ["bundled"]
[target.'cfg(not(windows))'.dependencies.coredump]
version = "=0.1.2"
version = "=0.1.2"

View file

@ -1,6 +1,6 @@
MIT License
Copyright (c) 2022 soywod <clement.douin@posteo.net>
Copyright (c) 2022-2023 soywod <clement.douin@posteo.net>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View file

@ -85,35 +85,22 @@ Please read the [documentation](https://pimalaya.org/himalaya/cli/configuration/
## Contributing
If you find a **bug** that [does not exist yet](https://todo.sr.ht/~soywod/pimalaya), please send an email at [~soywod/pimalaya@todo.sr.ht](mailto:~soywod/pimalaya@todo.sr.ht).
If you want to **report a bug** that [does not exist yet](https://todo.sr.ht/~soywod/pimalaya), please send an email at [~soywod/pimalaya@todo.sr.ht](mailto:~soywod/pimalaya@todo.sr.ht).
If you have a **question**, please send an email at [~soywod/pimalaya@lists.sr.ht](mailto:~soywod/pimalaya@lists.sr.ht).
If you want to **propose a feature** or **fix a bug**, please send a patch at [~soywod/pimalaya@lists.sr.ht](mailto:~soywod/pimalaya@lists.sr.ht) using [git send-email](https://git-scm.com/docs/git-send-email). Follow [this guide](https://git-send-email.io/) to configure git properly.
If you want to **propose a feature** or **fix a bug**, please send a patch at [~soywod/pimalaya@lists.sr.ht](mailto:~soywod/pimalaya@lists.sr.ht) using [git send-email](https://git-scm.com/docs/git-send-email) (see [this guide](https://git-send-email.io/) on how to configure it).
If you just want to **discuss** about the project, feel free to join the [Matrix](https://matrix.org/) workspace [#pimalaya.general](https://matrix.to/#/#pimalaya.general:matrix.org) or contact me directly [@soywod](https://matrix.to/#/@soywod:matrix.org). You can also use the mailing list [[send an email](mailto:~soywod/pimalaya@lists.sr.ht)|[subscribe](mailto:~soywod/pimalaya+subscribe@lists.sr.ht)|[unsubscribe](mailto:~soywod/pimalaya+unsubscribe@lists.sr.ht)].
If you want to **subscribe** to the mailing list, please send an email at [~soywod/pimalaya+subscribe@lists.sr.ht](mailto:~soywod/pimalaya+subscribe@lists.sr.ht).
If you want to **unsubscribe** to the mailing list, please send an email at [~soywod/pimalaya+unsubscribe@lists.sr.ht](mailto:~soywod/pimalaya+unsubscribe@lists.sr.ht).
If you want to **discuss** about the project, feel free to join the [Matrix](https://matrix.org/) workspace [#pimalaya.himalaya](https://matrix.to/#/#pimalaya.himalaya:matrix.org) or contact me directly [@soywod](https://matrix.to/#/@soywod:matrix.org).
## Credits
## Sponsoring
[![nlnet](https://nlnet.nl/logo/banner-160x60.png)](https://nlnet.nl/project/Himalaya/index.html)
Special thanks to the [nlnet](https://nlnet.nl/project/Himalaya/index.html) foundation that helped Himalaya to receive financial support from the [NGI Assure](https://www.ngi.eu/ngi-projects/ngi-assure/) program of the European Commission in September, 2022.
Special thanks to the [NLnet foundation](https://nlnet.nl/project/Himalaya/index.html) and the [European Commission](https://www.ngi.eu/) that helped the project to receive financial support from:
- [IMAP RFC3501](https://tools.ietf.org/html/rfc3501)
- [Iris](https://github.com/soywod/iris.vim), the himalaya predecessor
- [isync](https://isync.sourceforge.io/), an email synchronizer for offline usage
- [NeoMutt](https://neomutt.org/), an email terminal user interface
- [Alpine](http://alpine.x10host.com/alpine/alpine-info/), an other email terminal user interface
- [mutt-wizard](https://github.com/LukeSmithxyz/mutt-wizard), a tool over NeoMutt and isync
- [rust-imap](https://github.com/jonhoo/rust-imap), a Rust IMAP library
- [lettre](https://github.com/lettre/lettre), a Rust mailer library
- [mailparse](https://github.com/staktrace/mailparse), a Rust MIME email parser.
- [NGI Assure](https://nlnet.nl/assure/) in 2022
- [NGI Zero Untrust](https://nlnet.nl/entrust/) in 2023
## Sponsoring
If you appreciate the project, feel free to donate using one of the following providers:
[![GitHub](https://img.shields.io/badge/-GitHub%20Sponsors-fafbfc?logo=GitHub%20Sponsors)](https://github.com/sponsors/soywod)
[![PayPal](https://img.shields.io/badge/-PayPal-0079c1?logo=PayPal&logoColor=ffffff)](https://www.paypal.com/paypalme/soywod)

View file

@ -1,54 +1,77 @@
display-name = "Display NAME"
signature-delim = "~~"
signature = "~/.signature"
downloads-dir = "~/downloads"
folder-listing-page-size = 12
email-listing-page-size = 12
email-reading-headers = ["From", "To"]
email-reading-verify-cmd = "gpg --verify -q"
email-reading-decrypt-cmd = "gpg -dq"
email-writing-sign-cmd = "gpg -o - -saq"
email-writing-encrypt-cmd = "gpg -o - -eqar <recipient>"
[example]
# Make this account the default one to use when no account is given to
# commands.
default = true
display-name = "Display NAME (gmail)"
email = "display.name@gmail.local"
# The display-name and the email are used to build the full email
# address: "My example account" <example@localhost>
display-name = "My example account"
email = "example@localhost"
# The signature can be a string or a path to a file.
signature = "Regards,"
signature-delim = "-- \n"
# Enable the synchronization for this account. Running the command
# `account sync example` will synchronize all folders and all emails
# to a local Maildir at `$XDG_DATA_HOME/himalaya/example`.
sync.enable = true
# Override the default Maildir path for synchronization.
sync.dir = "/tmp/himalaya-sync-example"
# Define main folder aliases
folder.alias.inbox = "INBOX"
folder.alias.sent = "Sent"
folder.alias.drafts = "Drafts"
folder.alias.trash = "Trash"
# Also define custom folder aliases
folder.alias.prev-year = "Archives/2023"
# Default backend used for all the features like adding folders,
# listing envelopes or copying messages.
backend = "imap"
imap-host = "imap.gmail.com"
imap-login = "display.name@gmail.local"
imap-auth = "passwd"
imap-passwd.cmd = "pass show gmail"
imap-port = 993
imap-ssl = true
imap-starttls = false
imap-notify-cmd = """📫 "<sender>" "<subject>""""
imap-notify-query = "NOT SEEN"
imap-watch-cmds = ["echo \"received server changes!\""]
sender = "smtp"
smtp-host = "smtp.gmail.com"
smtp-login = "display.name@gmail.local"
smtp-auth = "passwd"
smtp-passwd.cmd = "pass show piana/gmail"
smtp-port = 465
smtp-ssl = true
smtp-starttls = false
envelope.list.page-size = 10
envelope.list.datetime-fmt = "%F %R%:z"
sync = true
sync-dir = "/tmp/sync/gmail"
sync-folders-strategy.include = ["INBOX"]
# Date are converted to the user's local timezone.
envelope.list.datetime-local-tz = true
[example.folder-aliases]
inbox = "INBOX"
drafts = "[Gmail]/Drafts"
sent = "[Gmail]/Sent Mail"
trash = "[Gmail]/Trash"
# Override the backend used for listing envelopes.
envelope.list.backend = "imap"
[example.email-hooks]
pre-send = "echo $1"
# Override the backend used for sending messages.
message.send.backend = "smtp"
[example.email-reading-format]
type = "fixed"
width = 64
# IMAP config
imap.host = "localhost"
imap.port = 3143
imap.login = "example@localhost"
imap.ssl = false
imap.starttls = false
imap.auth = "passwd" # or oauth2
# Get password from the raw string (not safe)
# imap.passwd.raw = "password"
# Get password from a shell command
imap.passwd.cmd = ["echo example-imap-password", "cat"]
# Get password from your system keyring using secret service
# Keyring secrets can be (re)set with the command `account configure example`
# imap.passwd.keyring = "example-imap-password"
# SMTP config
smtp.host = "localhost"
smtp.port = 3025
smtp.login = "example@localhost"
smtp.ssl = false
smtp.starttls = false
smtp.auth = "passwd"
smtp.passwd.raw = "password"
# PGP needs to be enabled with one of those cargo feature:
# pgp-commands, pgp-gpg or pgp-native
# pgp.backend = "gpg"

1
src/account/arg/mod.rs Normal file
View file

@ -0,0 +1 @@
pub mod name;

24
src/account/arg/name.rs Normal file
View file

@ -0,0 +1,24 @@
use clap::Parser;
/// The account name argument parser.
#[derive(Debug, Parser)]
pub struct AccountNameArg {
/// The name of the account.
///
/// An account name corresponds to an entry in the table at the
/// root level of your TOML configuration file.
#[arg(name = "account_name", value_name = "ACCOUNT")]
pub name: String,
}
/// The account name flag parser.
#[derive(Debug, Default, Parser)]
pub struct AccountNameFlag {
/// Override the default account.
///
/// An account name corresponds to an entry in the table at the
/// root level of your TOML configuration file.
#[arg(long = "account", short = 'a')]
#[arg(name = "account_name", value_name = "NAME")]
pub name: Option<String>,
}

View file

@ -0,0 +1,116 @@
use anyhow::Result;
use clap::Parser;
#[cfg(feature = "imap")]
use email::imap::config::ImapAuthConfig;
#[cfg(feature = "smtp")]
use email::smtp::config::SmtpAuthConfig;
use log::{debug, info, warn};
use crate::{
account::arg::name::AccountNameArg,
config::{
wizard::{prompt_passwd, prompt_secret},
TomlConfig,
},
printer::Printer,
};
/// Configure an account.
///
/// This command is mostly used to define or reset passwords managed
/// by your global keyring. If you do not use the keyring system, you
/// can skip this command.
#[derive(Debug, Parser)]
pub struct AccountConfigureCommand {
#[command(flatten)]
pub account: AccountNameArg,
/// Reset keyring passwords.
///
/// This argument will force passwords to be prompted again, then
/// saved to your global keyring.
#[arg(long, short)]
pub reset: bool,
}
impl AccountConfigureCommand {
pub async fn execute(self, printer: &mut impl Printer, config: &TomlConfig) -> Result<()> {
info!("executing account configure command");
let account = &self.account.name;
let (_, account_config) = config.into_toml_account_config(Some(account))?;
if self.reset {
#[cfg(feature = "imap")]
if let Some(ref config) = account_config.imap {
let reset = match &config.auth {
ImapAuthConfig::Passwd(config) => config.reset().await,
ImapAuthConfig::OAuth2(config) => config.reset().await,
};
if let Err(err) = reset {
warn!("error while resetting imap secrets: {err}");
debug!("error while resetting imap secrets: {err:?}");
}
}
#[cfg(feature = "smtp")]
if let Some(ref config) = account_config.smtp {
let reset = match &config.auth {
SmtpAuthConfig::Passwd(config) => config.reset().await,
SmtpAuthConfig::OAuth2(config) => config.reset().await,
};
if let Err(err) = reset {
warn!("error while resetting smtp secrets: {err}");
debug!("error while resetting smtp secrets: {err:?}");
}
}
#[cfg(feature = "pgp")]
if let Some(ref config) = account_config.pgp {
config.reset().await?;
}
}
#[cfg(feature = "imap")]
if let Some(ref config) = account_config.imap {
match &config.auth {
ImapAuthConfig::Passwd(config) => {
config.configure(|| prompt_passwd("IMAP password")).await
}
ImapAuthConfig::OAuth2(config) => {
config
.configure(|| prompt_secret("IMAP OAuth 2.0 client secret"))
.await
}
}?;
}
#[cfg(feature = "smtp")]
if let Some(ref config) = account_config.smtp {
match &config.auth {
SmtpAuthConfig::Passwd(config) => {
config.configure(|| prompt_passwd("SMTP password")).await
}
SmtpAuthConfig::OAuth2(config) => {
config
.configure(|| prompt_secret("SMTP OAuth 2.0 client secret"))
.await
}
}?;
}
#[cfg(feature = "pgp")]
if let Some(ref config) = account_config.pgp {
config
.configure(&account_config.email, || {
prompt_passwd("PGP secret key password")
})
.await?;
}
printer.print(format!(
"Account {account} successfully {}configured!",
if self.reset { "re" } else { "" }
))
}
}

139
src/account/command/list.rs Normal file
View file

@ -0,0 +1,139 @@
use anyhow::Result;
use clap::Parser;
use log::info;
use crate::{
account::Accounts,
config::TomlConfig,
printer::{PrintTableOpts, Printer},
ui::arg::max_width::TableMaxWidthFlag,
};
/// List all accounts.
///
/// This command lists all accounts defined in your TOML configuration
/// file.
#[derive(Debug, Parser)]
pub struct AccountListCommand {
#[command(flatten)]
pub table: TableMaxWidthFlag,
}
impl AccountListCommand {
pub async fn execute(self, printer: &mut impl Printer, config: &TomlConfig) -> Result<()> {
info!("executing account list command");
let accounts: Accounts = config.accounts.iter().into();
printer.print_table(
Box::new(accounts),
PrintTableOpts {
format: &Default::default(),
max_width: self.table.max_width,
},
)
}
}
#[cfg(test)]
mod tests {
use email::{account::config::AccountConfig, imap::config::ImapConfig};
use std::{collections::HashMap, fmt::Debug, io};
use termcolor::ColorSpec;
use crate::{
account::TomlAccountConfig,
backend::BackendKind,
printer::{Print, PrintTable, WriteColor},
};
use super::*;
#[test]
fn it_should_match_cmds_accounts() {
#[derive(Debug, Default, Clone)]
struct StringWriter {
content: String,
}
impl io::Write for StringWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.content
.push_str(&String::from_utf8(buf.to_vec()).unwrap());
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
self.content = String::default();
Ok(())
}
}
impl termcolor::WriteColor for StringWriter {
fn supports_color(&self) -> bool {
false
}
fn set_color(&mut self, _spec: &ColorSpec) -> io::Result<()> {
io::Result::Ok(())
}
fn reset(&mut self) -> io::Result<()> {
io::Result::Ok(())
}
}
impl WriteColor for StringWriter {}
#[derive(Debug, Default)]
struct PrinterServiceTest {
pub writer: StringWriter,
}
impl Printer for PrinterServiceTest {
fn print_table<T: Debug + PrintTable + erased_serde::Serialize + ?Sized>(
&mut self,
data: Box<T>,
opts: PrintTableOpts,
) -> Result<()> {
data.print_table(&mut self.writer, opts)?;
Ok(())
}
fn print_log<T: Debug + Print>(&mut self, _data: T) -> Result<()> {
unimplemented!()
}
fn print<T: Debug + Print + serde::Serialize>(&mut self, _data: T) -> Result<()> {
unimplemented!()
}
fn is_json(&self) -> bool {
unimplemented!()
}
}
let mut printer = PrinterServiceTest::default();
let config = AccountConfig::default();
let deserialized_config = TomlConfig {
accounts: HashMap::from_iter([(
"account-1".into(),
TomlAccountConfig {
default: Some(true),
backend: Some(BackendKind::Imap),
imap: Some(ImapConfig::default()),
..Default::default()
},
)]),
..TomlConfig::default()
};
assert!(list(None, &config, &deserialized_config, &mut printer).is_ok());
assert_eq!(
concat![
"\n",
"NAME │BACKEND │DEFAULT \n",
"account-1 │imap │yes \n",
"\n"
],
printer.writer.content
);
}
}

View file

@ -0,0 +1,39 @@
mod configure;
mod list;
mod sync;
use anyhow::Result;
use clap::Subcommand;
use crate::{config::TomlConfig, printer::Printer};
use self::{
configure::AccountConfigureCommand, list::AccountListCommand, sync::AccountSyncCommand,
};
/// Manage accounts.
///
/// An account is a set of settings, identified by an account
/// name. Settings are directly taken from your TOML configuration
/// file. This subcommand allows you to manage them.
#[derive(Debug, Subcommand)]
pub enum AccountSubcommand {
#[command(alias = "cfg")]
Configure(AccountConfigureCommand),
#[command(alias = "lst")]
List(AccountListCommand),
#[command(alias = "synchronize", alias = "synchronise")]
Sync(AccountSyncCommand),
}
impl AccountSubcommand {
pub async fn execute(self, printer: &mut impl Printer, config: &TomlConfig) -> Result<()> {
match self {
Self::Configure(cmd) => cmd.execute(printer, config).await,
Self::List(cmd) => cmd.execute(printer, config).await,
Self::Sync(cmd) => cmd.execute(printer, config).await,
}
}
}

258
src/account/command/sync.rs Normal file
View file

@ -0,0 +1,258 @@
use anyhow::Result;
use clap::{ArgAction, Parser};
use email::{
account::sync::{AccountSyncBuilder, AccountSyncProgressEvent},
folder::sync::FolderSyncStrategy,
};
use indicatif::{MultiProgress, ProgressBar, ProgressFinish, ProgressStyle};
use log::info;
use once_cell::sync::Lazy;
use std::{
collections::{HashMap, HashSet},
sync::Mutex,
};
use crate::{
account::arg::name::AccountNameArg, backend::BackendBuilder, config::TomlConfig,
printer::Printer,
};
const MAIN_PROGRESS_STYLE: Lazy<ProgressStyle> = Lazy::new(|| {
ProgressStyle::with_template(" {spinner:.dim} {msg:.dim}\n {wide_bar:.cyan/blue} \n").unwrap()
});
const SUB_PROGRESS_STYLE: Lazy<ProgressStyle> = Lazy::new(|| {
ProgressStyle::with_template(
" {prefix:.bold} — {wide_msg:.dim} \n {wide_bar:.black/black} {percent}% ",
)
.unwrap()
});
const SUB_PROGRESS_DONE_STYLE: Lazy<ProgressStyle> = Lazy::new(|| {
ProgressStyle::with_template(" {prefix:.bold} \n {wide_bar:.green} {percent}% ").unwrap()
});
/// Synchronize an account.
///
/// This command allows you to synchronize all folders and emails
/// (including envelopes and messages) of a given account into a local
/// Maildir folder.
#[derive(Debug, Parser)]
pub struct AccountSyncCommand {
#[command(flatten)]
pub account: AccountNameArg,
/// Run the synchronization without applying any changes.
///
/// Instead, a report will be printed to stdout containing all the
/// changes the synchronization plan to do.
#[arg(long, short)]
pub dry_run: bool,
/// Synchronize only specific folders.
///
/// Only the given folders will be synchronized (including
/// associated envelopes and messages). Useful when you need to
/// speed up the synchronization process. A good usecase is to
/// synchronize only the INBOX in order to quickly check for new
/// messages.
#[arg(long, short = 'f')]
#[arg(value_name = "FOLDER", action = ArgAction::Append)]
#[arg(conflicts_with = "exclude_folder", conflicts_with = "all_folders")]
pub include_folder: Vec<String>,
/// Omit specific folders from the synchronization.
///
/// The given folders will be excluded from the synchronization
/// (including associated envelopes and messages). Useful when you
/// have heavy folders that you do not want to take care of, or to
/// speed up the synchronization process.
#[arg(long, short = 'x')]
#[arg(value_name = "FOLDER", action = ArgAction::Append)]
#[arg(conflicts_with = "include_folder", conflicts_with = "all_folders")]
pub exclude_folder: Vec<String>,
/// Synchronize all exsting folders.
#[arg(long, short = 'A')]
#[arg(conflicts_with = "include_folder", conflicts_with = "exclude_folder")]
pub all_folders: bool,
}
impl AccountSyncCommand {
pub async fn execute(self, printer: &mut impl Printer, config: &TomlConfig) -> Result<()> {
info!("executing account sync command");
let included_folders = HashSet::from_iter(self.include_folder);
let excluded_folders = HashSet::from_iter(self.exclude_folder);
let strategy = if !included_folders.is_empty() {
Some(FolderSyncStrategy::Include(included_folders))
} else if !excluded_folders.is_empty() {
Some(FolderSyncStrategy::Exclude(excluded_folders))
} else if self.all_folders {
Some(FolderSyncStrategy::All)
} else {
None
};
let (toml_account_config, account_config) = config
.clone()
.into_account_configs(Some(self.account.name.as_str()), true)?;
let backend_builder =
BackendBuilder::new(toml_account_config, account_config.clone(), false).await?;
let sync_builder = AccountSyncBuilder::new(backend_builder.into())
.await?
.with_some_folders_strategy(strategy)
.with_dry_run(self.dry_run);
if self.dry_run {
let report = sync_builder.sync().await?;
let mut hunks_count = report.folders_patch.len();
if !report.folders_patch.is_empty() {
printer.print_log("Folders patch:")?;
for (hunk, _) in report.folders_patch {
printer.print_log(format!(" - {hunk}"))?;
}
printer.print_log("")?;
}
if !report.emails_patch.is_empty() {
printer.print_log("Envelopes patch:")?;
for (hunk, _) in report.emails_patch {
hunks_count += 1;
printer.print_log(format!(" - {hunk}"))?;
}
printer.print_log("")?;
}
printer.print(format!(
"Estimated patch length for account {} to be synchronized: {hunks_count}",
self.account.name
))?;
} else if printer.is_json() {
sync_builder.sync().await?;
printer.print(format!(
"Account {} successfully synchronized!",
self.account.name
))?;
} else {
let multi = MultiProgress::new();
let sub_progresses = Mutex::new(HashMap::new());
let main_progress = multi.add(
ProgressBar::new(100)
.with_style(MAIN_PROGRESS_STYLE.clone())
.with_message("Synchronizing folders…"),
);
// Force the progress bar to show
main_progress.set_position(0);
let report = sync_builder
.with_on_progress(move |evt| {
use AccountSyncProgressEvent::*;
Ok(match evt {
ApplyFolderPatches(..) => {
main_progress.inc(3);
}
ApplyEnvelopePatches(patches) => {
let mut envelopes_progresses = sub_progresses.lock().unwrap();
let patches_len =
patches.values().fold(0, |sum, patch| sum + patch.len());
main_progress.set_length((110 * patches_len / 100) as u64);
main_progress.set_position((5 * patches_len / 100) as u64);
main_progress.set_message("Synchronizing envelopes…");
for (folder, patch) in patches {
let progress = ProgressBar::new(patch.len() as u64)
.with_style(SUB_PROGRESS_STYLE.clone())
.with_prefix(folder.clone())
.with_finish(ProgressFinish::AndClear);
let progress = multi.add(progress);
envelopes_progresses.insert(folder, progress.clone());
}
}
ApplyEnvelopeHunk(hunk) => {
main_progress.inc(1);
let mut progresses = sub_progresses.lock().unwrap();
if let Some(progress) = progresses.get_mut(hunk.folder()) {
progress.inc(1);
if progress.position() == (progress.length().unwrap() - 1) {
progress.set_style(SUB_PROGRESS_DONE_STYLE.clone())
} else {
progress.set_message(format!("{hunk}"));
}
}
}
ApplyEnvelopeCachePatch(_patch) => {
main_progress.set_length(100);
main_progress.set_position(95);
main_progress.set_message("Saving cache database…");
}
ExpungeFolders(folders) => {
let mut progresses = sub_progresses.lock().unwrap();
for progress in progresses.values() {
progress.finish_and_clear()
}
progresses.clear();
main_progress.set_position(100);
main_progress
.set_message(format!("Expunging {} folders…", folders.len()));
}
_ => (),
})
})
.sync()
.await?;
let folders_patch_err = report
.folders_patch
.iter()
.filter_map(|(hunk, err)| err.as_ref().map(|err| (hunk, err)))
.collect::<Vec<_>>();
if !folders_patch_err.is_empty() {
printer.print_log("")?;
printer.print_log("Errors occurred while applying the folders patch:")?;
folders_patch_err
.iter()
.try_for_each(|(hunk, err)| printer.print_log(format!(" - {hunk}: {err}")))?;
}
if let Some(err) = report.folders_cache_patch.1 {
printer.print_log("")?;
printer.print_log(format!(
"Error occurred while applying the folder cache patch: {err}"
))?;
}
let envelopes_patch_err = report
.emails_patch
.iter()
.filter_map(|(hunk, err)| err.as_ref().map(|err| (hunk, err)))
.collect::<Vec<_>>();
if !envelopes_patch_err.is_empty() {
printer.print_log("")?;
printer.print_log("Errors occurred while applying the envelopes patch:")?;
for (hunk, err) in folders_patch_err {
printer.print_log(format!(" - {hunk}: {err}"))?;
}
}
if let Some(err) = report.emails_cache_patch.1 {
printer.print_log("")?;
printer.print_log(format!(
"Error occurred while applying the envelopes cache patch: {err}"
))?;
}
printer.print(format!(
"Account {} successfully synchronized!",
self.account.name
))?;
}
Ok(())
}
}

219
src/account/config.rs Normal file
View file

@ -0,0 +1,219 @@
//! Deserialized account config module.
//!
//! This module contains the raw deserialized representation of an
//! account in the accounts section of the user configuration file.
#[cfg(feature = "pgp")]
use email::account::config::pgp::PgpConfig;
#[cfg(feature = "imap")]
use email::imap::config::ImapConfig;
#[cfg(feature = "smtp")]
use email::smtp::config::SmtpConfig;
use email::{
account::sync::config::SyncConfig, maildir::config::MaildirConfig,
sendmail::config::SendmailConfig,
};
use serde::{Deserialize, Serialize};
use std::{collections::HashSet, path::PathBuf};
use crate::{
backend::BackendKind, envelope::config::EnvelopeConfig, flag::config::FlagConfig,
folder::config::FolderConfig, message::config::MessageConfig,
};
/// Represents all existing kind of account config.
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub struct TomlAccountConfig {
pub default: Option<bool>,
pub email: String,
pub display_name: Option<String>,
pub signature: Option<String>,
pub signature_delim: Option<String>,
pub downloads_dir: Option<PathBuf>,
pub sync: Option<SyncConfig>,
pub folder: Option<FolderConfig>,
pub envelope: Option<EnvelopeConfig>,
pub flag: Option<FlagConfig>,
pub message: Option<MessageConfig>,
#[cfg(feature = "pgp")]
pub pgp: Option<PgpConfig>,
pub backend: Option<BackendKind>,
#[cfg(feature = "maildir")]
pub maildir: Option<MaildirConfig>,
#[cfg(feature = "imap")]
pub imap: Option<ImapConfig>,
#[cfg(feature = "notmuch")]
pub notmuch: Option<NotmuchConfig>,
#[cfg(feature = "smtp")]
pub smtp: Option<SmtpConfig>,
#[cfg(feature = "sendmail")]
pub sendmail: Option<SendmailConfig>,
}
impl TomlAccountConfig {
pub fn add_folder_kind(&self) -> Option<&BackendKind> {
self.folder
.as_ref()
.and_then(|folder| folder.add.as_ref())
.and_then(|add| add.backend.as_ref())
.or_else(|| self.backend.as_ref())
}
pub fn list_folders_kind(&self) -> Option<&BackendKind> {
self.folder
.as_ref()
.and_then(|folder| folder.list.as_ref())
.and_then(|list| list.backend.as_ref())
.or_else(|| self.backend.as_ref())
}
pub fn expunge_folder_kind(&self) -> Option<&BackendKind> {
self.folder
.as_ref()
.and_then(|folder| folder.expunge.as_ref())
.and_then(|expunge| expunge.backend.as_ref())
.or_else(|| self.backend.as_ref())
}
pub fn purge_folder_kind(&self) -> Option<&BackendKind> {
self.folder
.as_ref()
.and_then(|folder| folder.purge.as_ref())
.and_then(|purge| purge.backend.as_ref())
.or_else(|| self.backend.as_ref())
}
pub fn delete_folder_kind(&self) -> Option<&BackendKind> {
self.folder
.as_ref()
.and_then(|folder| folder.delete.as_ref())
.and_then(|delete| delete.backend.as_ref())
.or_else(|| self.backend.as_ref())
}
pub fn get_envelope_kind(&self) -> Option<&BackendKind> {
self.envelope
.as_ref()
.and_then(|envelope| envelope.get.as_ref())
.and_then(|get| get.backend.as_ref())
.or_else(|| self.backend.as_ref())
}
pub fn list_envelopes_kind(&self) -> Option<&BackendKind> {
self.envelope
.as_ref()
.and_then(|envelope| envelope.list.as_ref())
.and_then(|list| list.backend.as_ref())
.or_else(|| self.backend.as_ref())
}
pub fn add_flags_kind(&self) -> Option<&BackendKind> {
self.flag
.as_ref()
.and_then(|flag| flag.add.as_ref())
.and_then(|add| add.backend.as_ref())
.or_else(|| self.backend.as_ref())
}
pub fn set_flags_kind(&self) -> Option<&BackendKind> {
self.flag
.as_ref()
.and_then(|flag| flag.set.as_ref())
.and_then(|set| set.backend.as_ref())
.or_else(|| self.backend.as_ref())
}
pub fn remove_flags_kind(&self) -> Option<&BackendKind> {
self.flag
.as_ref()
.and_then(|flag| flag.remove.as_ref())
.and_then(|remove| remove.backend.as_ref())
.or_else(|| self.backend.as_ref())
}
pub fn add_raw_message_kind(&self) -> Option<&BackendKind> {
self.message
.as_ref()
.and_then(|msg| msg.write.as_ref())
.and_then(|add| add.backend.as_ref())
.or_else(|| self.backend.as_ref())
}
pub fn peek_messages_kind(&self) -> Option<&BackendKind> {
self.message
.as_ref()
.and_then(|message| message.peek.as_ref())
.and_then(|peek| peek.backend.as_ref())
.or_else(|| self.backend.as_ref())
}
pub fn get_messages_kind(&self) -> Option<&BackendKind> {
self.message
.as_ref()
.and_then(|message| message.read.as_ref())
.and_then(|get| get.backend.as_ref())
.or_else(|| self.backend.as_ref())
}
pub fn copy_messages_kind(&self) -> Option<&BackendKind> {
self.message
.as_ref()
.and_then(|message| message.copy.as_ref())
.and_then(|copy| copy.backend.as_ref())
.or_else(|| self.backend.as_ref())
}
pub fn move_messages_kind(&self) -> Option<&BackendKind> {
self.message
.as_ref()
.and_then(|message| message.move_.as_ref())
.and_then(|move_| move_.backend.as_ref())
.or_else(|| self.backend.as_ref())
}
pub fn delete_messages_kind(&self) -> Option<&BackendKind> {
self.flag
.as_ref()
.and_then(|flag| flag.remove.as_ref())
.and_then(|remove| remove.backend.as_ref())
.or_else(|| self.backend.as_ref())
}
pub fn send_raw_message_kind(&self) -> Option<&BackendKind> {
self.message
.as_ref()
.and_then(|msg| msg.send.as_ref())
.and_then(|send| send.backend.as_ref())
.or_else(|| self.backend.as_ref())
}
pub fn get_used_backends(&self) -> HashSet<&BackendKind> {
let mut used_backends = HashSet::default();
if let Some(ref kind) = self.backend {
used_backends.insert(kind);
}
if let Some(ref folder) = self.folder {
used_backends.extend(folder.get_used_backends());
}
if let Some(ref envelope) = self.envelope {
used_backends.extend(envelope.get_used_backends());
}
if let Some(ref flag) = self.flag {
used_backends.extend(flag.get_used_backends());
}
if let Some(ref msg) = self.message {
used_backends.extend(msg.get_used_backends());
}
used_backends
}
}

132
src/account/mod.rs Normal file
View file

@ -0,0 +1,132 @@
pub mod arg;
pub mod command;
pub mod config;
pub(crate) mod wizard;
use anyhow::Result;
use serde::Serialize;
use std::{collections::hash_map::Iter, fmt, ops::Deref};
use crate::{
printer::{PrintTable, PrintTableOpts, WriteColor},
ui::table::{Cell, Row, Table},
};
use self::config::TomlAccountConfig;
/// Represents the printable account.
#[derive(Debug, Default, PartialEq, Eq, Serialize)]
pub struct Account {
/// Represents the account name.
pub name: String,
/// Represents the backend name of the account.
pub backend: String,
/// Represents the default state of the account.
pub default: bool,
}
impl Account {
pub fn new(name: &str, backend: &str, default: bool) -> Self {
Self {
name: name.into(),
backend: backend.into(),
default,
}
}
}
impl fmt::Display for Account {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}", self.name)
}
}
impl Table for Account {
fn head() -> Row {
Row::new()
.cell(Cell::new("NAME").shrinkable().bold().underline().white())
.cell(Cell::new("BACKENDS").bold().underline().white())
.cell(Cell::new("DEFAULT").bold().underline().white())
}
fn row(&self) -> Row {
let default = if self.default { "yes" } else { "" };
Row::new()
.cell(Cell::new(&self.name).shrinkable().green())
.cell(Cell::new(&self.backend).blue())
.cell(Cell::new(default).white())
}
}
/// Represents the list of printable accounts.
#[derive(Debug, Default, Serialize)]
pub struct Accounts(pub Vec<Account>);
impl Deref for Accounts {
type Target = Vec<Account>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl PrintTable for Accounts {
fn print_table(&self, writer: &mut dyn WriteColor, opts: PrintTableOpts) -> Result<()> {
writeln!(writer)?;
Table::print(writer, self, opts)?;
writeln!(writer)?;
Ok(())
}
}
impl From<Iter<'_, String, TomlAccountConfig>> for Accounts {
fn from(map: Iter<'_, String, TomlAccountConfig>) -> Self {
let mut accounts: Vec<_> = map
.map(|(name, account)| {
let mut backends = String::new();
#[cfg(feature = "imap")]
if account.imap.is_some() {
backends.push_str("imap");
}
if account.maildir.is_some() {
if !backends.is_empty() {
backends.push_str(", ")
}
backends.push_str("maildir");
}
#[cfg(feature = "notmuch")]
if account.imap.is_some() {
if !backends.is_empty() {
backends.push_str(", ")
}
backends.push_str("notmuch");
}
#[cfg(feature = "smtp")]
if account.smtp.is_some() {
if !backends.is_empty() {
backends.push_str(", ")
}
backends.push_str("smtp");
}
if account.sendmail.is_some() {
if !backends.is_empty() {
backends.push_str(", ")
}
backends.push_str("sendmail");
}
Account::new(name, &backends, account.default.unwrap_or_default())
})
.collect();
// sort accounts by name
accounts.sort_by(|a, b| a.name.partial_cmp(&b.name).unwrap());
Self(accounts)
}
}

107
src/account/wizard.rs Normal file
View file

@ -0,0 +1,107 @@
use anyhow::{bail, Result};
use dialoguer::{Confirm, Input};
use email::account::sync::config::SyncConfig;
use email_address::EmailAddress;
use crate::{
backend::{self, config::BackendConfig, BackendKind},
config::wizard::THEME,
message::config::{MessageConfig, MessageSendConfig},
wizard_prompt,
};
use super::TomlAccountConfig;
pub(crate) async fn configure() -> Result<Option<(String, TomlAccountConfig)>> {
let mut config = TomlAccountConfig::default();
let account_name = Input::with_theme(&*THEME)
.with_prompt("Account name")
.default(String::from("Personal"))
.interact()?;
config.email = Input::with_theme(&*THEME)
.with_prompt("Email address")
.validate_with(|email: &String| {
if EmailAddress::is_valid(email) {
Ok(())
} else {
bail!("Invalid email address: {email}")
}
})
.interact()?;
config.display_name = Some(
Input::with_theme(&*THEME)
.with_prompt("Full display name")
.interact()?,
);
config.downloads_dir = Some(
Input::with_theme(&*THEME)
.with_prompt("Downloads directory")
.default(String::from("~/Downloads"))
.interact()?
.into(),
);
match backend::wizard::configure(&account_name, &config.email).await? {
Some(BackendConfig::Maildir(mdir_config)) => {
config.maildir = Some(mdir_config);
config.backend = Some(BackendKind::Maildir);
}
#[cfg(feature = "imap")]
Some(BackendConfig::Imap(imap_config)) => {
config.imap = Some(imap_config);
config.backend = Some(BackendKind::Imap);
}
#[cfg(feature = "notmuch")]
Some(BackendConfig::Notmuch(notmuch_config)) => {
config.notmuch = Some(notmuch_config);
config.backend = Some(BackendKind::Notmuch);
}
_ => (),
};
match backend::wizard::configure_sender(&account_name, &config.email).await? {
Some(BackendConfig::Sendmail(sendmail_config)) => {
config.sendmail = Some(sendmail_config);
config.message = Some(MessageConfig {
send: Some(MessageSendConfig {
backend: Some(BackendKind::Sendmail),
..Default::default()
}),
..Default::default()
});
}
#[cfg(feature = "smtp")]
Some(BackendConfig::Smtp(smtp_config)) => {
config.smtp = Some(smtp_config);
config.message = Some(MessageConfig {
send: Some(MessageSendConfig {
backend: Some(BackendKind::Smtp),
..Default::default()
}),
..Default::default()
});
}
_ => (),
};
let should_configure_sync = Confirm::new()
.with_prompt(wizard_prompt!(
"Do you need an offline access to your account?"
))
.default(false)
.interact_opt()?
.unwrap_or_default();
if should_configure_sync {
config.sync = Some(SyncConfig {
enable: Some(true),
..Default::default()
});
}
Ok(Some((account_name, config)))
}

19
src/backend/config.rs Normal file
View file

@ -0,0 +1,19 @@
#[cfg(feature = "imap")]
use email::imap::config::ImapConfig;
#[cfg(feature = "notmuch")]
use email::notmuch::config::NotmuchConfig;
#[cfg(feature = "smtp")]
use email::smtp::config::SmtpConfig;
use email::{maildir::config::MaildirConfig, sendmail::config::SendmailConfig};
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum BackendConfig {
Maildir(MaildirConfig),
#[cfg(feature = "imap")]
Imap(ImapConfig),
#[cfg(feature = "notmuch")]
Notmuch(NotmuchConfig),
#[cfg(feature = "smtp")]
Smtp(SmtpConfig),
Sendmail(SendmailConfig),
}

832
src/backend/mod.rs Normal file
View file

@ -0,0 +1,832 @@
pub mod config;
pub(crate) mod wizard;
use anyhow::Result;
use async_trait::async_trait;
use std::ops::Deref;
#[cfg(feature = "imap")]
use email::imap::{ImapSessionBuilder, ImapSessionSync};
#[cfg(feature = "smtp")]
use email::smtp::{SmtpClientBuilder, SmtpClientSync};
use email::{
account::config::AccountConfig,
envelope::{
get::{imap::GetEnvelopeImap, maildir::GetEnvelopeMaildir},
list::{imap::ListEnvelopesImap, maildir::ListEnvelopesMaildir},
Id, SingleId,
},
flag::{
add::{imap::AddFlagsImap, maildir::AddFlagsMaildir},
remove::{imap::RemoveFlagsImap, maildir::RemoveFlagsMaildir},
set::{imap::SetFlagsImap, maildir::SetFlagsMaildir},
Flag, Flags,
},
folder::{
add::{imap::AddFolderImap, maildir::AddFolderMaildir},
delete::{imap::DeleteFolderImap, maildir::DeleteFolderMaildir},
expunge::{imap::ExpungeFolderImap, maildir::ExpungeFolderMaildir},
list::{imap::ListFoldersImap, maildir::ListFoldersMaildir},
purge::imap::PurgeFolderImap,
},
maildir::{config::MaildirConfig, MaildirSessionBuilder, MaildirSessionSync},
message::{
add_raw::imap::AddRawMessageImap,
add_raw_with_flags::{
imap::AddRawMessageWithFlagsImap, maildir::AddRawMessageWithFlagsMaildir,
},
copy::{imap::CopyMessagesImap, maildir::CopyMessagesMaildir},
get::imap::GetMessagesImap,
move_::{imap::MoveMessagesImap, maildir::MoveMessagesMaildir},
peek::{imap::PeekMessagesImap, maildir::PeekMessagesMaildir},
send_raw::{sendmail::SendRawMessageSendmail, smtp::SendRawMessageSmtp},
Messages,
},
sendmail::SendmailContext,
};
use serde::{Deserialize, Serialize};
use crate::{account::config::TomlAccountConfig, cache::IdMapper, envelope::Envelopes};
#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum BackendKind {
Maildir,
#[serde(skip_deserializing)]
MaildirForSync,
#[cfg(feature = "imap")]
Imap,
#[cfg(feature = "notmuch")]
Notmuch,
#[cfg(feature = "smtp")]
Smtp,
Sendmail,
}
impl ToString for BackendKind {
fn to_string(&self) -> String {
let kind = match self {
Self::Maildir => "Maildir",
Self::MaildirForSync => "Maildir",
#[cfg(feature = "imap")]
Self::Imap => "IMAP",
#[cfg(feature = "notmuch")]
Self::Notmuch => "Notmuch",
#[cfg(feature = "smtp")]
Self::Smtp => "SMTP",
Self::Sendmail => "Sendmail",
};
kind.to_string()
}
}
#[derive(Clone, Default)]
pub struct BackendContextBuilder {
maildir: Option<MaildirSessionBuilder>,
maildir_for_sync: Option<MaildirSessionBuilder>,
#[cfg(feature = "imap")]
imap: Option<ImapSessionBuilder>,
#[cfg(feature = "smtp")]
smtp: Option<SmtpClientBuilder>,
sendmail: Option<SendmailContext>,
}
#[async_trait]
impl email::backend::BackendContextBuilder for BackendContextBuilder {
type Context = BackendContext;
async fn build(self) -> Result<Self::Context> {
let mut ctx = BackendContext::default();
if let Some(maildir) = self.maildir {
ctx.maildir = Some(maildir.build().await?);
}
if let Some(maildir) = self.maildir_for_sync {
ctx.maildir_for_sync = Some(maildir.build().await?);
}
#[cfg(feature = "imap")]
if let Some(imap) = self.imap {
ctx.imap = Some(imap.build().await?);
}
#[cfg(feature = "notmuch")]
if let Some(notmuch) = self.notmuch {
ctx.notmuch = Some(notmuch.build().await?);
}
#[cfg(feature = "smtp")]
if let Some(smtp) = self.smtp {
ctx.smtp = Some(smtp.build().await?);
}
if let Some(sendmail) = self.sendmail {
ctx.sendmail = Some(sendmail.build().await?);
}
Ok(ctx)
}
}
#[derive(Default)]
pub struct BackendContext {
pub maildir: Option<MaildirSessionSync>,
pub maildir_for_sync: Option<MaildirSessionSync>,
#[cfg(feature = "imap")]
pub imap: Option<ImapSessionSync>,
#[cfg(feature = "smtp")]
pub smtp: Option<SmtpClientSync>,
pub sendmail: Option<SendmailContext>,
}
pub struct BackendBuilder {
toml_account_config: TomlAccountConfig,
builder: email::backend::BackendBuilder<BackendContextBuilder>,
}
impl BackendBuilder {
pub async fn new(
toml_account_config: TomlAccountConfig,
account_config: AccountConfig,
with_sending: bool,
) -> Result<Self> {
let used_backends = toml_account_config.get_used_backends();
let is_maildir_used = used_backends.contains(&BackendKind::Maildir);
let is_maildir_for_sync_used = used_backends.contains(&BackendKind::MaildirForSync);
#[cfg(feature = "imap")]
let is_imap_used = used_backends.contains(&BackendKind::Imap);
#[cfg(feature = "notmuch")]
let is_notmuch_used = used_backends.contains(&BackendKind::Notmuch);
#[cfg(feature = "smtp")]
let is_smtp_used = used_backends.contains(&BackendKind::Smtp);
let is_sendmail_used = used_backends.contains(&BackendKind::Sendmail);
let backend_ctx_builder = BackendContextBuilder {
maildir: toml_account_config
.maildir
.as_ref()
.filter(|_| is_maildir_used)
.map(|mdir_config| {
MaildirSessionBuilder::new(account_config.clone(), mdir_config.clone())
}),
maildir_for_sync: Some(MaildirConfig {
root_dir: account_config.get_sync_dir()?,
})
.filter(|_| is_maildir_for_sync_used)
.map(|mdir_config| MaildirSessionBuilder::new(account_config.clone(), mdir_config)),
#[cfg(feature = "imap")]
imap: {
let ctx_builder = toml_account_config
.imap
.as_ref()
.filter(|_| is_imap_used)
.map(|imap_config| {
ImapSessionBuilder::new(account_config.clone(), imap_config.clone())
.with_prebuilt_credentials()
});
match ctx_builder {
Some(ctx_builder) => Some(ctx_builder.await?),
None => None,
}
},
#[cfg(feature = "notmuch")]
notmuch: toml_account_config
.notmuch
.as_ref()
.filter(|_| is_notmuch_used)
.map(|notmuch_config| {
NotmuchSessionBuilder::new(account_config.clone(), notmuch_config.clone())
}),
#[cfg(feature = "smtp")]
smtp: toml_account_config
.smtp
.as_ref()
.filter(|_| with_sending)
.filter(|_| is_smtp_used)
.map(|smtp_config| {
SmtpClientBuilder::new(account_config.clone(), smtp_config.clone())
}),
sendmail: toml_account_config
.sendmail
.as_ref()
.filter(|_| with_sending)
.filter(|_| is_sendmail_used)
.map(|sendmail_config| {
SendmailContext::new(account_config.clone(), sendmail_config.clone())
}),
};
let mut backend_builder =
email::backend::BackendBuilder::new(account_config.clone(), backend_ctx_builder);
match toml_account_config.add_folder_kind() {
Some(BackendKind::Maildir) => {
backend_builder = backend_builder
.with_add_folder(|ctx| ctx.maildir.as_ref().and_then(AddFolderMaildir::new));
}
Some(BackendKind::MaildirForSync) => {
backend_builder = backend_builder.with_add_folder(|ctx| {
ctx.maildir_for_sync
.as_ref()
.and_then(AddFolderMaildir::new)
});
}
#[cfg(feature = "imap")]
Some(BackendKind::Imap) => {
backend_builder = backend_builder
.with_add_folder(|ctx| ctx.imap.as_ref().and_then(AddFolderImap::new));
}
#[cfg(feature = "notmuch")]
Some(BackendKind::Notmuch) => {
backend_builder = backend_builder
.with_add_folder(|ctx| ctx.notmuch.as_ref().and_then(AddFolderNotmuch::new));
}
_ => (),
}
match toml_account_config.list_folders_kind() {
Some(BackendKind::Maildir) => {
backend_builder = backend_builder.with_list_folders(|ctx| {
ctx.maildir.as_ref().and_then(ListFoldersMaildir::new)
});
}
Some(BackendKind::MaildirForSync) => {
backend_builder = backend_builder.with_list_folders(|ctx| {
ctx.maildir_for_sync
.as_ref()
.and_then(ListFoldersMaildir::new)
});
}
#[cfg(feature = "imap")]
Some(BackendKind::Imap) => {
backend_builder = backend_builder
.with_list_folders(|ctx| ctx.imap.as_ref().and_then(ListFoldersImap::new));
}
#[cfg(feature = "notmuch")]
Some(BackendKind::Notmuch) => {
backend_builder = backend_builder.with_list_folders(|ctx| {
ctx.notmuch.as_ref().and_then(ListFoldersNotmuch::new)
});
}
_ => (),
}
match toml_account_config.expunge_folder_kind() {
Some(BackendKind::Maildir) => {
backend_builder = backend_builder.with_expunge_folder(|ctx| {
ctx.maildir.as_ref().and_then(ExpungeFolderMaildir::new)
});
}
Some(BackendKind::MaildirForSync) => {
backend_builder = backend_builder.with_expunge_folder(|ctx| {
ctx.maildir_for_sync
.as_ref()
.and_then(ExpungeFolderMaildir::new)
});
}
#[cfg(feature = "imap")]
Some(BackendKind::Imap) => {
backend_builder = backend_builder
.with_expunge_folder(|ctx| ctx.imap.as_ref().and_then(ExpungeFolderImap::new));
}
#[cfg(feature = "notmuch")]
Some(BackendKind::Notmuch) => {
backend_builder = backend_builder.with_expunge_folder(|ctx| {
ctx.notmuch.as_ref().and_then(ExpungeFolderNotmuch::new)
});
}
_ => (),
}
match toml_account_config.purge_folder_kind() {
// TODO
// Some(BackendKind::Maildir) => {
// backend_builder = backend_builder
// .with_purge_folder(|ctx| ctx.maildir.as_ref().and_then(PurgeFolderMaildir::new));
// }
// TODO
// Some(BackendKind::MaildirForSync) => {
// backend_builder = backend_builder
// .with_purge_folder(|ctx| ctx.maildir_for_sync.as_ref().and_then(PurgeFolderMaildir::new));
// }
#[cfg(feature = "imap")]
Some(BackendKind::Imap) => {
backend_builder = backend_builder
.with_purge_folder(|ctx| ctx.imap.as_ref().and_then(PurgeFolderImap::new));
}
#[cfg(feature = "notmuch")]
Some(BackendKind::Notmuch) => {
backend_builder = backend_builder.with_purge_folder(|ctx| {
ctx.notmuch.as_ref().and_then(PurgeFolderNotmuch::new)
});
}
_ => (),
}
match toml_account_config.delete_folder_kind() {
Some(BackendKind::Maildir) => {
backend_builder = backend_builder.with_delete_folder(|ctx| {
ctx.maildir.as_ref().and_then(DeleteFolderMaildir::new)
});
}
Some(BackendKind::MaildirForSync) => {
backend_builder = backend_builder.with_delete_folder(|ctx| {
ctx.maildir_for_sync
.as_ref()
.and_then(DeleteFolderMaildir::new)
});
}
#[cfg(feature = "imap")]
Some(BackendKind::Imap) => {
backend_builder = backend_builder
.with_delete_folder(|ctx| ctx.imap.as_ref().and_then(DeleteFolderImap::new));
}
#[cfg(feature = "notmuch")]
Some(BackendKind::Notmuch) => {
backend_builder = backend_builder.with_delete_folder(|ctx| {
ctx.notmuch.as_ref().and_then(DeleteFolderNotmuch::new)
});
}
_ => (),
}
match toml_account_config.get_envelope_kind() {
Some(BackendKind::Maildir) => {
backend_builder = backend_builder.with_get_envelope(|ctx| {
ctx.maildir.as_ref().and_then(GetEnvelopeMaildir::new)
});
}
Some(BackendKind::MaildirForSync) => {
backend_builder = backend_builder.with_get_envelope(|ctx| {
ctx.maildir_for_sync
.as_ref()
.and_then(GetEnvelopeMaildir::new)
});
}
#[cfg(feature = "imap")]
Some(BackendKind::Imap) => {
backend_builder = backend_builder
.with_get_envelope(|ctx| ctx.imap.as_ref().and_then(GetEnvelopeImap::new));
}
#[cfg(feature = "notmuch")]
Some(BackendKind::Notmuch) => {
backend_builder = backend_builder.with_get_envelope(|ctx| {
ctx.notmuch.as_ref().and_then(GetEnvelopeNotmuch::new)
});
}
_ => (),
}
match toml_account_config.list_envelopes_kind() {
Some(BackendKind::Maildir) => {
backend_builder = backend_builder.with_list_envelopes(|ctx| {
ctx.maildir.as_ref().and_then(ListEnvelopesMaildir::new)
});
}
Some(BackendKind::MaildirForSync) => {
backend_builder = backend_builder.with_list_envelopes(|ctx| {
ctx.maildir_for_sync
.as_ref()
.and_then(ListEnvelopesMaildir::new)
});
}
#[cfg(feature = "imap")]
Some(BackendKind::Imap) => {
backend_builder = backend_builder
.with_list_envelopes(|ctx| ctx.imap.as_ref().and_then(ListEnvelopesImap::new));
}
#[cfg(feature = "notmuch")]
Some(BackendKind::Notmuch) => {
backend_builder = backend_builder.with_list_envelopes(|ctx| {
ctx.notmuch.as_ref().and_then(ListEnvelopesNotmuch::new)
});
}
_ => (),
}
match toml_account_config.add_flags_kind() {
Some(BackendKind::Maildir) => {
backend_builder = backend_builder
.with_add_flags(|ctx| ctx.maildir.as_ref().and_then(AddFlagsMaildir::new));
}
Some(BackendKind::MaildirForSync) => {
backend_builder = backend_builder.with_add_flags(|ctx| {
ctx.maildir_for_sync.as_ref().and_then(AddFlagsMaildir::new)
});
}
#[cfg(feature = "imap")]
Some(BackendKind::Imap) => {
backend_builder = backend_builder
.with_add_flags(|ctx| ctx.imap.as_ref().and_then(AddFlagsImap::new));
}
#[cfg(feature = "notmuch")]
Some(BackendKind::Notmuch) => {
backend_builder = backend_builder
.with_add_flags(|ctx| ctx.notmuch.as_ref().and_then(AddFlagsNotmuch::new));
}
_ => (),
}
match toml_account_config.set_flags_kind() {
Some(BackendKind::Maildir) => {
backend_builder = backend_builder
.with_set_flags(|ctx| ctx.maildir.as_ref().and_then(SetFlagsMaildir::new));
}
Some(BackendKind::MaildirForSync) => {
backend_builder = backend_builder.with_set_flags(|ctx| {
ctx.maildir_for_sync.as_ref().and_then(SetFlagsMaildir::new)
});
}
#[cfg(feature = "imap")]
Some(BackendKind::Imap) => {
backend_builder = backend_builder
.with_set_flags(|ctx| ctx.imap.as_ref().and_then(SetFlagsImap::new));
}
#[cfg(feature = "notmuch")]
Some(BackendKind::Notmuch) => {
backend_builder = backend_builder
.with_set_flags(|ctx| ctx.notmuch.as_ref().and_then(SetFlagsNotmuch::new));
}
_ => (),
}
match toml_account_config.remove_flags_kind() {
Some(BackendKind::Maildir) => {
backend_builder = backend_builder.with_remove_flags(|ctx| {
ctx.maildir.as_ref().and_then(RemoveFlagsMaildir::new)
});
}
Some(BackendKind::MaildirForSync) => {
backend_builder = backend_builder.with_remove_flags(|ctx| {
ctx.maildir_for_sync
.as_ref()
.and_then(RemoveFlagsMaildir::new)
});
}
#[cfg(feature = "imap")]
Some(BackendKind::Imap) => {
backend_builder = backend_builder
.with_remove_flags(|ctx| ctx.imap.as_ref().and_then(RemoveFlagsImap::new));
}
#[cfg(feature = "notmuch")]
Some(BackendKind::Notmuch) => {
backend_builder = backend_builder.with_remove_flags(|ctx| {
ctx.notmuch.as_ref().and_then(RemoveFlagsNotmuch::new)
});
}
_ => (),
}
match toml_account_config.send_raw_message_kind() {
#[cfg(feature = "smtp")]
Some(BackendKind::Smtp) => {
backend_builder = backend_builder.with_send_raw_message(|ctx| {
ctx.smtp.as_ref().and_then(SendRawMessageSmtp::new)
});
}
Some(BackendKind::Sendmail) => {
backend_builder = backend_builder.with_send_raw_message(|ctx| {
ctx.sendmail.as_ref().and_then(SendRawMessageSendmail::new)
});
}
_ => (),
}
match toml_account_config.add_raw_message_kind() {
Some(BackendKind::Maildir) => {
backend_builder = backend_builder.with_add_raw_message_with_flags(|ctx| {
ctx.maildir
.as_ref()
.and_then(AddRawMessageWithFlagsMaildir::new)
});
}
Some(BackendKind::MaildirForSync) => {
backend_builder = backend_builder.with_add_raw_message_with_flags(|ctx| {
ctx.maildir_for_sync
.as_ref()
.and_then(AddRawMessageWithFlagsMaildir::new)
});
}
#[cfg(feature = "imap")]
Some(BackendKind::Imap) => {
backend_builder = backend_builder
.with_add_raw_message(|ctx| ctx.imap.as_ref().and_then(AddRawMessageImap::new))
.with_add_raw_message_with_flags(|ctx| {
ctx.imap.as_ref().and_then(AddRawMessageWithFlagsImap::new)
});
}
#[cfg(feature = "notmuch")]
Some(BackendKind::Notmuch) => {
backend_builder = backend_builder.with_add_raw_message(|ctx| {
ctx.notmuch.as_ref().and_then(AddRawMessageNotmuch::new)
});
}
_ => (),
}
match toml_account_config.peek_messages_kind() {
Some(BackendKind::Maildir) => {
backend_builder = backend_builder.with_peek_messages(|ctx| {
ctx.maildir.as_ref().and_then(PeekMessagesMaildir::new)
});
}
Some(BackendKind::MaildirForSync) => {
backend_builder = backend_builder.with_peek_messages(|ctx| {
ctx.maildir_for_sync
.as_ref()
.and_then(PeekMessagesMaildir::new)
});
}
#[cfg(feature = "imap")]
Some(BackendKind::Imap) => {
backend_builder = backend_builder
.with_peek_messages(|ctx| ctx.imap.as_ref().and_then(PeekMessagesImap::new));
}
#[cfg(feature = "notmuch")]
Some(BackendKind::Notmuch) => {
backend_builder = backend_builder.with_peek_messages(|ctx| {
ctx.notmuch.as_ref().and_then(PeekMessagesNotmuch::new)
});
}
_ => (),
}
match toml_account_config.get_messages_kind() {
#[cfg(feature = "imap")]
Some(BackendKind::Imap) => {
backend_builder = backend_builder
.with_get_messages(|ctx| ctx.imap.as_ref().and_then(GetMessagesImap::new));
}
#[cfg(feature = "notmuch")]
Some(BackendKind::Notmuch) => {
backend_builder = backend_builder.with_get_messages(|ctx| {
ctx.notmuch.as_ref().and_then(GetMessagesNotmuch::new)
});
}
_ => (),
}
match toml_account_config.copy_messages_kind() {
Some(BackendKind::Maildir) => {
backend_builder = backend_builder.with_copy_messages(|ctx| {
ctx.maildir.as_ref().and_then(CopyMessagesMaildir::new)
});
}
Some(BackendKind::MaildirForSync) => {
backend_builder = backend_builder.with_copy_messages(|ctx| {
ctx.maildir_for_sync
.as_ref()
.and_then(CopyMessagesMaildir::new)
});
}
#[cfg(feature = "imap")]
Some(BackendKind::Imap) => {
backend_builder = backend_builder
.with_copy_messages(|ctx| ctx.imap.as_ref().and_then(CopyMessagesImap::new));
}
#[cfg(feature = "notmuch")]
Some(BackendKind::Notmuch) => {
backend_builder = backend_builder.with_copy_messages(|ctx| {
ctx.notmuch.as_ref().and_then(CopyMessagesNotmuch::new)
});
}
_ => (),
}
match toml_account_config.move_messages_kind() {
Some(BackendKind::Maildir) => {
backend_builder = backend_builder.with_move_messages(|ctx| {
ctx.maildir.as_ref().and_then(MoveMessagesMaildir::new)
});
}
Some(BackendKind::MaildirForSync) => {
backend_builder = backend_builder.with_move_messages(|ctx| {
ctx.maildir_for_sync
.as_ref()
.and_then(MoveMessagesMaildir::new)
});
}
#[cfg(feature = "imap")]
Some(BackendKind::Imap) => {
backend_builder = backend_builder
.with_move_messages(|ctx| ctx.imap.as_ref().and_then(MoveMessagesImap::new));
}
#[cfg(feature = "notmuch")]
Some(BackendKind::Notmuch) => {
backend_builder = backend_builder.with_move_messages(|ctx| {
ctx.notmuch.as_ref().and_then(MoveMessagesNotmuch::new)
});
}
_ => (),
}
Ok(Self {
toml_account_config,
builder: backend_builder,
})
}
pub async fn build(self) -> Result<Backend> {
Ok(Backend {
toml_account_config: self.toml_account_config,
backend: self.builder.build().await?,
})
}
}
impl Deref for BackendBuilder {
type Target = email::backend::BackendBuilder<BackendContextBuilder>;
fn deref(&self) -> &Self::Target {
&self.builder
}
}
impl Into<email::backend::BackendBuilder<BackendContextBuilder>> for BackendBuilder {
fn into(self) -> email::backend::BackendBuilder<BackendContextBuilder> {
self.builder
}
}
pub struct Backend {
toml_account_config: TomlAccountConfig,
backend: email::backend::Backend<BackendContext>,
}
impl Backend {
pub async fn new(
toml_account_config: TomlAccountConfig,
account_config: AccountConfig,
with_sending: bool,
) -> Result<Self> {
BackendBuilder::new(toml_account_config, account_config, with_sending)
.await?
.build()
.await
}
fn build_id_mapper(
&self,
folder: &str,
backend_kind: Option<&BackendKind>,
) -> Result<IdMapper> {
let mut id_mapper = IdMapper::Dummy;
match backend_kind {
Some(BackendKind::Maildir) => {
if let Some(mdir_config) = &self.toml_account_config.maildir {
id_mapper = IdMapper::new(
&self.backend.account_config,
folder,
mdir_config.root_dir.clone(),
)?;
}
}
Some(BackendKind::MaildirForSync) => {
id_mapper = IdMapper::new(
&self.backend.account_config,
folder,
self.backend.account_config.get_sync_dir()?,
)?;
}
#[cfg(feature = "notmuch")]
Some(BackendKind::Notmuch) => {
if let Some(notmuch_config) = &self.toml_account_config.notmuch {
id_mapper = IdMapper::new(
&self.backend.account_config,
folder,
mdir_config.root_dir.clone(),
)?;
}
}
_ => (),
};
Ok(id_mapper)
}
pub async fn list_envelopes(
&self,
folder: &str,
page_size: usize,
page: usize,
) -> Result<Envelopes> {
let backend_kind = self.toml_account_config.list_envelopes_kind();
let id_mapper = self.build_id_mapper(folder, backend_kind)?;
let envelopes = self.backend.list_envelopes(folder, page_size, page).await?;
let envelopes = Envelopes::from_backend(&self.account_config, &id_mapper, envelopes)?;
Ok(envelopes)
}
pub async fn add_flags(&self, folder: &str, ids: &[usize], flags: &Flags) -> Result<()> {
let backend_kind = self.toml_account_config.add_flags_kind();
let id_mapper = self.build_id_mapper(folder, backend_kind)?;
let ids = Id::multiple(id_mapper.get_ids(ids)?);
self.backend.add_flags(folder, &ids, flags).await
}
pub async fn set_flags(&self, folder: &str, ids: &[usize], flags: &Flags) -> Result<()> {
let backend_kind = self.toml_account_config.set_flags_kind();
let id_mapper = self.build_id_mapper(folder, backend_kind)?;
let ids = Id::multiple(id_mapper.get_ids(ids)?);
self.backend.set_flags(folder, &ids, flags).await
}
pub async fn remove_flags(&self, folder: &str, ids: &[usize], flags: &Flags) -> Result<()> {
let backend_kind = self.toml_account_config.remove_flags_kind();
let id_mapper = self.build_id_mapper(folder, backend_kind)?;
let ids = Id::multiple(id_mapper.get_ids(ids)?);
self.backend.remove_flags(folder, &ids, flags).await
}
pub async fn peek_messages(&self, folder: &str, ids: &[usize]) -> Result<Messages> {
let backend_kind = self.toml_account_config.get_messages_kind();
let id_mapper = self.build_id_mapper(folder, backend_kind)?;
let ids = Id::multiple(id_mapper.get_ids(ids)?);
self.backend.peek_messages(folder, &ids).await
}
pub async fn get_messages(&self, folder: &str, ids: &[usize]) -> Result<Messages> {
let backend_kind = self.toml_account_config.get_messages_kind();
let id_mapper = self.build_id_mapper(folder, backend_kind)?;
let ids = Id::multiple(id_mapper.get_ids(ids)?);
self.backend.get_messages(folder, &ids).await
}
pub async fn copy_messages(
&self,
from_folder: &str,
to_folder: &str,
ids: &[usize],
) -> Result<()> {
let backend_kind = self.toml_account_config.move_messages_kind();
let id_mapper = self.build_id_mapper(from_folder, backend_kind)?;
let ids = Id::multiple(id_mapper.get_ids(ids)?);
self.backend
.copy_messages(from_folder, to_folder, &ids)
.await
}
pub async fn move_messages(
&self,
from_folder: &str,
to_folder: &str,
ids: &[usize],
) -> Result<()> {
let backend_kind = self.toml_account_config.move_messages_kind();
let id_mapper = self.build_id_mapper(from_folder, backend_kind)?;
let ids = Id::multiple(id_mapper.get_ids(ids)?);
self.backend
.move_messages(from_folder, to_folder, &ids)
.await
}
pub async fn delete_messages(&self, folder: &str, ids: &[usize]) -> Result<()> {
let backend_kind = self.toml_account_config.delete_messages_kind();
let id_mapper = self.build_id_mapper(folder, backend_kind)?;
let ids = Id::multiple(id_mapper.get_ids(ids)?);
self.backend.delete_messages(folder, &ids).await
}
pub async fn add_raw_message(&self, folder: &str, email: &[u8]) -> Result<SingleId> {
let backend_kind = self.toml_account_config.add_raw_message_kind();
let id_mapper = self.build_id_mapper(folder, backend_kind)?;
let id = self.backend.add_raw_message(folder, email).await?;
id_mapper.create_alias(&*id)?;
Ok(id)
}
pub async fn add_flag(&self, folder: &str, ids: &[usize], flag: Flag) -> Result<()> {
let backend_kind = self.toml_account_config.add_flags_kind();
let id_mapper = self.build_id_mapper(folder, backend_kind)?;
let ids = Id::multiple(id_mapper.get_ids(ids)?);
self.backend.add_flag(folder, &ids, flag).await
}
pub async fn set_flag(&self, folder: &str, ids: &[usize], flag: Flag) -> Result<()> {
let backend_kind = self.toml_account_config.set_flags_kind();
let id_mapper = self.build_id_mapper(folder, backend_kind)?;
let ids = Id::multiple(id_mapper.get_ids(ids)?);
self.backend.set_flag(folder, &ids, flag).await
}
pub async fn remove_flag(&self, folder: &str, ids: &[usize], flag: Flag) -> Result<()> {
let backend_kind = self.toml_account_config.remove_flags_kind();
let id_mapper = self.build_id_mapper(folder, backend_kind)?;
let ids = Id::multiple(id_mapper.get_ids(ids)?);
self.backend.remove_flag(folder, &ids, flag).await
}
}
impl Deref for Backend {
type Target = email::backend::Backend<BackendContext>;
fn deref(&self) -> &Self::Target {
&self.backend
}
}

71
src/backend/wizard.rs Normal file
View file

@ -0,0 +1,71 @@
use anyhow::Result;
use dialoguer::Select;
#[cfg(feature = "imap")]
use crate::imap;
#[cfg(feature = "notmuch")]
use crate::notmuch;
#[cfg(feature = "smtp")]
use crate::smtp;
use crate::{config::wizard::THEME, maildir, sendmail};
use super::{config::BackendConfig, BackendKind};
const DEFAULT_BACKEND_KINDS: &[BackendKind] = &[
#[cfg(feature = "imap")]
BackendKind::Imap,
BackendKind::Maildir,
#[cfg(feature = "notmuch")]
BackendKind::Notmuch,
];
const SEND_MESSAGE_BACKEND_KINDS: &[BackendKind] = &[
#[cfg(feature = "smtp")]
BackendKind::Smtp,
BackendKind::Sendmail,
];
pub(crate) async fn configure(account_name: &str, email: &str) -> Result<Option<BackendConfig>> {
let kind = Select::with_theme(&*THEME)
.with_prompt("Default email backend")
.items(DEFAULT_BACKEND_KINDS)
.default(0)
.interact_opt()?
.and_then(|idx| DEFAULT_BACKEND_KINDS.get(idx).map(Clone::clone));
let config = match kind {
Some(kind) if kind == BackendKind::Maildir => Some(maildir::wizard::configure()?),
#[cfg(feature = "imap")]
Some(kind) if kind == BackendKind::Imap => {
Some(imap::wizard::configure(account_name, email).await?)
}
#[cfg(feature = "notmuch")]
Some(kind) if kind == BackendKind::Notmuch => Some(notmuch::wizard::configure()?),
_ => None,
};
Ok(config)
}
pub(crate) async fn configure_sender(
account_name: &str,
email: &str,
) -> Result<Option<BackendConfig>> {
let kind = Select::with_theme(&*THEME)
.with_prompt("Backend for sending messages")
.items(SEND_MESSAGE_BACKEND_KINDS)
.default(0)
.interact_opt()?
.and_then(|idx| SEND_MESSAGE_BACKEND_KINDS.get(idx).map(Clone::clone));
let config = match kind {
Some(kind) if kind == BackendKind::Sendmail => Some(sendmail::wizard::configure()?),
#[cfg(feature = "smtp")]
Some(kind) if kind == BackendKind::Smtp => {
Some(smtp::wizard::configure(account_name, email).await?)
}
_ => None,
};
Ok(config)
}

15
src/cache/arg/disable.rs vendored Normal file
View file

@ -0,0 +1,15 @@
use clap::Parser;
/// The disable cache flag parser.
#[derive(Debug, Default, Parser)]
pub struct CacheDisableFlag {
/// Disable any sort of cache.
///
/// The action depends on commands it apply on. For example, when
/// listing envelopes using the IMAP backend, this flag will
/// ensure that envelopes are fetched from the IMAP server rather
/// than the synchronized local Maildir.
#[arg(long = "disable-cache", alias = "no-cache", global = true)]
#[arg(name = "cache_disable")]
pub disable: bool,
}

1
src/cache/arg/mod.rs vendored Normal file
View file

@ -0,0 +1 @@
pub mod disable;

17
src/cache/args.rs vendored
View file

@ -6,19 +6,24 @@ const ARG_DISABLE_CACHE: &str = "disable-cache";
/// Represents the disable cache flag argument. This argument allows
/// the user to disable any sort of cache.
pub fn arg() -> Arg {
Arg::new(ARG_DISABLE_CACHE)
pub fn global_args() -> impl IntoIterator<Item = Arg> {
[Arg::new(ARG_DISABLE_CACHE)
.help("Disable any sort of cache")
.long_help(
"Disable any sort of cache. The action depends on
the command it applies on.",
"Disable any sort of cache.
The action depends on commands it apply on. For example, when listing
envelopes using the IMAP backend, this flag will ensure that envelopes
are fetched from the IMAP server and not from the synchronized local
Maildir.",
)
.long("disable-cache")
.alias("no-cache")
.global(true)
.action(ArgAction::SetTrue)
.action(ArgAction::SetTrue)]
}
/// Represents the disable cache flag parser.
pub fn parse_disable_cache_flag(m: &ArgMatches) -> bool {
pub fn parse_disable_cache_arg(m: &ArgMatches) -> bool {
m.get_flag(ARG_DISABLE_CACHE)
}

192
src/cache/id_mapper.rs vendored
View file

@ -1,192 +0,0 @@
use anyhow::{anyhow, Context, Result};
#[cfg(feature = "imap-backend")]
use email::backend::ImapBackend;
#[cfg(feature = "notmuch-backend")]
use email::backend::NotmuchBackend;
use email::{
account::AccountConfig,
backend::{Backend, MaildirBackend},
};
use log::{debug, trace};
use std::path::{Path, PathBuf};
const ID_MAPPER_DB_FILE_NAME: &str = ".id-mapper.sqlite";
#[derive(Debug)]
pub enum IdMapper {
Dummy,
Mapper(String, rusqlite::Connection),
}
impl IdMapper {
pub fn find_closest_db_path(dir: impl AsRef<Path>) -> PathBuf {
let mut db_path = dir.as_ref().join(ID_MAPPER_DB_FILE_NAME);
let mut db_parent_dir = dir.as_ref().parent();
while !db_path.is_file() {
match db_parent_dir {
Some(dir) => {
db_path = dir.join(ID_MAPPER_DB_FILE_NAME);
db_parent_dir = dir.parent();
}
None => {
db_path = dir.as_ref().join(ID_MAPPER_DB_FILE_NAME);
break;
}
}
}
db_path
}
pub fn new(
backend: &dyn Backend,
account_config: &AccountConfig,
folder: &str,
) -> Result<Self> {
#[cfg(feature = "imap-backend")]
if backend.as_any().is::<ImapBackend>() {
return Ok(IdMapper::Dummy);
}
let mut db_path = PathBuf::new();
if let Some(backend) = backend.as_any().downcast_ref::<MaildirBackend>() {
db_path = Self::find_closest_db_path(backend.path())
}
#[cfg(feature = "notmuch-backend")]
if let Some(backend) = backend.as_any().downcast_ref::<NotmuchBackend>() {
db_path = Self::find_closest_db_path(backend.path())
}
let folder = account_config.get_folder_alias(folder)?;
let digest = md5::compute(account_config.name.clone() + &folder);
let table = format!("id_mapper_{digest:x}");
debug!("creating id mapper table {table} at {db_path:?}…");
let conn = rusqlite::Connection::open(&db_path)
.with_context(|| format!("cannot open id mapper database at {db_path:?}"))?;
let query = format!(
"CREATE TABLE IF NOT EXISTS {table} (
id INTEGER PRIMARY KEY AUTOINCREMENT,
internal_id TEXT UNIQUE
)",
);
trace!("create table query: {query:#?}");
conn.execute(&query, [])
.context("cannot create id mapper table")?;
Ok(Self::Mapper(table, conn))
}
pub fn create_alias<I>(&self, id: I) -> Result<String>
where
I: AsRef<str>,
{
let id = id.as_ref();
match self {
Self::Dummy => Ok(id.to_owned()),
Self::Mapper(table, conn) => {
debug!("creating alias for id {id}…");
let query = format!("INSERT OR IGNORE INTO {} (internal_id) VALUES (?)", table);
trace!("insert query: {query:#?}");
conn.execute(&query, [id])
.with_context(|| format!("cannot create id alias for id {id}"))?;
let alias = conn.last_insert_rowid().to_string();
debug!("created alias {alias} for id {id}");
Ok(alias)
}
}
}
pub fn get_or_create_alias<I>(&self, id: I) -> Result<String>
where
I: AsRef<str>,
{
let id = id.as_ref();
match self {
Self::Dummy => Ok(id.to_owned()),
Self::Mapper(table, conn) => {
debug!("getting alias for id {id}…");
let query = format!("SELECT id FROM {} WHERE internal_id = ?", table);
trace!("select query: {query:#?}");
let mut stmt = conn
.prepare(&query)
.with_context(|| format!("cannot get alias for id {id}"))?;
let aliases: Vec<i64> = stmt
.query_map([id], |row| row.get(0))
.with_context(|| format!("cannot get alias for id {id}"))?
.collect::<rusqlite::Result<_>>()
.with_context(|| format!("cannot get alias for id {id}"))?;
let alias = match aliases.first() {
Some(alias) => {
debug!("found alias {alias} for id {id}");
alias.to_string()
}
None => {
debug!("alias not found, creating it…");
self.create_alias(id)?
}
};
Ok(alias)
}
}
}
pub fn get_id<A>(&self, alias: A) -> Result<String>
where
A: AsRef<str>,
{
let alias = alias.as_ref();
let alias = alias
.parse::<i64>()
.context(format!("cannot parse id mapper alias {alias}"))?;
match self {
Self::Dummy => Ok(alias.to_string()),
Self::Mapper(table, conn) => {
debug!("getting id from alias {alias}…");
let query = format!("SELECT internal_id FROM {} WHERE id = ?", table);
trace!("select query: {query:#?}");
let mut stmt = conn
.prepare(&query)
.with_context(|| format!("cannot get id from alias {alias}"))?;
let ids: Vec<String> = stmt
.query_map([alias], |row| row.get(0))
.with_context(|| format!("cannot get id from alias {alias}"))?
.collect::<rusqlite::Result<_>>()
.with_context(|| format!("cannot get id from alias {alias}"))?;
let id = ids
.first()
.ok_or_else(|| anyhow!("cannot get id from alias {alias}"))?
.to_owned();
debug!("found id {id} from alias {alias}");
Ok(id)
}
}
}
pub fn get_ids<A, I>(&self, aliases: I) -> Result<Vec<String>>
where
A: AsRef<str>,
I: IntoIterator<Item = A>,
{
aliases
.into_iter()
.map(|alias| self.get_id(alias))
.collect()
}
}

169
src/cache/mod.rs vendored
View file

@ -1,4 +1,169 @@
pub mod arg;
pub mod args;
mod id_mapper;
pub use id_mapper::IdMapper;
use anyhow::{anyhow, Context, Result};
use email::account::config::AccountConfig;
use log::{debug, trace};
use std::path::{Path, PathBuf};
const ID_MAPPER_DB_FILE_NAME: &str = ".id-mapper.sqlite";
#[derive(Debug)]
pub enum IdMapper {
Dummy,
Mapper(String, rusqlite::Connection),
}
impl IdMapper {
pub fn find_closest_db_path(dir: impl AsRef<Path>) -> PathBuf {
let mut db_path = dir.as_ref().join(ID_MAPPER_DB_FILE_NAME);
let mut db_parent_dir = dir.as_ref().parent();
while !db_path.is_file() {
match db_parent_dir {
Some(dir) => {
db_path = dir.join(ID_MAPPER_DB_FILE_NAME);
db_parent_dir = dir.parent();
}
None => {
db_path = dir.as_ref().join(ID_MAPPER_DB_FILE_NAME);
break;
}
}
}
db_path
}
pub fn new(account_config: &AccountConfig, folder: &str, db_path: PathBuf) -> Result<Self> {
let folder = account_config.get_folder_alias(folder)?;
let digest = md5::compute(account_config.name.clone() + &folder);
let table = format!("id_mapper_{digest:x}");
debug!("creating id mapper table {table} at {db_path:?}…");
let db_path = Self::find_closest_db_path(db_path);
let conn = rusqlite::Connection::open(&db_path)
.with_context(|| format!("cannot open id mapper database at {db_path:?}"))?;
let query = format!(
"CREATE TABLE IF NOT EXISTS {table} (
id INTEGER PRIMARY KEY AUTOINCREMENT,
internal_id TEXT UNIQUE
)",
);
trace!("create table query: {query:#?}");
conn.execute(&query, [])
.context("cannot create id mapper table")?;
Ok(Self::Mapper(table, conn))
}
pub fn create_alias<I>(&self, id: I) -> Result<String>
where
I: AsRef<str>,
{
let id = id.as_ref();
match self {
Self::Dummy => Ok(id.to_owned()),
Self::Mapper(table, conn) => {
debug!("creating alias for id {id}…");
let query = format!("INSERT OR IGNORE INTO {} (internal_id) VALUES (?)", table);
trace!("insert query: {query:#?}");
conn.execute(&query, [id])
.with_context(|| format!("cannot create id alias for id {id}"))?;
let alias = conn.last_insert_rowid().to_string();
debug!("created alias {alias} for id {id}");
Ok(alias)
}
}
}
pub fn get_or_create_alias<I>(&self, id: I) -> Result<String>
where
I: AsRef<str>,
{
let id = id.as_ref();
match self {
Self::Dummy => Ok(id.to_owned()),
Self::Mapper(table, conn) => {
debug!("getting alias for id {id}…");
let query = format!("SELECT id FROM {} WHERE internal_id = ?", table);
trace!("select query: {query:#?}");
let mut stmt = conn
.prepare(&query)
.with_context(|| format!("cannot get alias for id {id}"))?;
let aliases: Vec<i64> = stmt
.query_map([id], |row| row.get(0))
.with_context(|| format!("cannot get alias for id {id}"))?
.collect::<rusqlite::Result<_>>()
.with_context(|| format!("cannot get alias for id {id}"))?;
let alias = match aliases.first() {
Some(alias) => {
debug!("found alias {alias} for id {id}");
alias.to_string()
}
None => {
debug!("alias not found, creating it…");
self.create_alias(id)?
}
};
Ok(alias)
}
}
}
pub fn get_id<A>(&self, alias: A) -> Result<String>
where
A: ToString,
{
let alias = alias.to_string();
let alias = alias
.parse::<i64>()
.context(format!("cannot parse id mapper alias {alias}"))?;
match self {
Self::Dummy => Ok(alias.to_string()),
Self::Mapper(table, conn) => {
debug!("getting id from alias {alias}…");
let query = format!("SELECT internal_id FROM {} WHERE id = ?", table);
trace!("select query: {query:#?}");
let mut stmt = conn
.prepare(&query)
.with_context(|| format!("cannot get id from alias {alias}"))?;
let ids: Vec<String> = stmt
.query_map([alias], |row| row.get(0))
.with_context(|| format!("cannot get id from alias {alias}"))?
.collect::<rusqlite::Result<_>>()
.with_context(|| format!("cannot get id from alias {alias}"))?;
let id = ids
.first()
.ok_or_else(|| anyhow!("cannot get id from alias {alias}"))?
.to_owned();
debug!("found id {id} from alias {alias}");
Ok(id)
}
}
}
pub fn get_ids<A, I>(&self, aliases: I) -> Result<Vec<String>>
where
A: ToString,
I: IntoIterator<Item = A>,
{
aliases
.into_iter()
.map(|alias| self.get_id(alias))
.collect()
}
}

132
src/cli.rs Normal file
View file

@ -0,0 +1,132 @@
use anyhow::Result;
use clap::{Parser, Subcommand};
use std::path::PathBuf;
use crate::{
account::command::AccountSubcommand,
completion::command::CompletionGenerateCommand,
config::{self, TomlConfig},
envelope::command::EnvelopeSubcommand,
flag::command::FlagSubcommand,
folder::command::FolderSubcommand,
manual::command::ManualGenerateCommand,
message::{
attachment::command::AttachmentSubcommand, command::MessageSubcommand,
template::command::TemplateSubcommand,
},
output::{ColorFmt, OutputFmt},
printer::Printer,
};
#[derive(Parser, Debug)]
#[command(name = "himalaya", author, version, about)]
#[command(propagate_version = true, infer_subcommands = true)]
pub struct Cli {
#[command(subcommand)]
pub command: HimalayaCommand,
/// Override the default configuration file path
///
/// The given path is shell-expanded then canonicalized (if
/// applicable). If the path does not point to a valid file, the
/// wizard will propose to assist you in the creation of the
/// configuration file.
#[arg(long, short, global = true)]
#[arg(value_name = "PATH", value_parser = config::path_parser)]
pub config: Option<PathBuf>,
/// Customize the output format
///
/// The output format determine how to display commands output to
/// the terminal.
///
/// The possible values are:
///
/// - json: output will be in a form of a JSON-compatible object
///
/// - plain: output will be in a form of either a plain text or
/// table, depending on the command
#[arg(long, short, global = true)]
#[arg(value_name = "FORMAT", value_enum, default_value_t = Default::default())]
pub output: OutputFmt,
/// Control when to use colors
///
/// The default setting is 'auto', which means himalaya will try
/// to guess when to use colors. For example, if himalaya is
/// printing to a terminal, then it will use colors, but if it is
/// redirected to a file or a pipe, then it will suppress color
/// output. himalaya will suppress color output in some other
/// circumstances as well. For example, if the TERM environment
/// variable is not set or set to 'dumb', then himalaya will not
/// use colors.
///
/// The possible values are:
///
/// - never: colors will never be used
///
/// - always: colors will always be used regardless of where output is sent
///
/// - ansi: like 'always', but emits ANSI escapes (even in a Windows console)
///
/// - auto: himalaya tries to be smart
#[arg(long, short = 'C', global = true)]
#[arg(value_name = "MODE", value_enum, default_value_t = Default::default())]
pub color: ColorFmt,
}
#[derive(Subcommand, Debug)]
pub enum HimalayaCommand {
#[command(subcommand)]
#[command(alias = "accounts")]
Account(AccountSubcommand),
#[command(subcommand)]
#[command(visible_alias = "mailbox", aliases = ["mailboxes", "mboxes", "mbox"])]
#[command(alias = "folders")]
Folder(FolderSubcommand),
#[command(subcommand)]
#[command(alias = "envelopes")]
Envelope(EnvelopeSubcommand),
#[command(subcommand)]
#[command(alias = "flags")]
Flag(FlagSubcommand),
#[command(subcommand)]
#[command(alias = "messages", alias = "msgs", alias = "msg")]
Message(MessageSubcommand),
#[command(subcommand)]
#[command(alias = "attachments")]
Attachment(AttachmentSubcommand),
#[command(subcommand)]
#[command(alias = "templates", alias = "tpls", alias = "tpl")]
Template(TemplateSubcommand),
#[command(arg_required_else_help = true)]
#[command(alias = "manuals", alias = "mans")]
Manual(ManualGenerateCommand),
#[command(arg_required_else_help = true)]
#[command(alias = "completions")]
Completion(CompletionGenerateCommand),
}
impl HimalayaCommand {
pub async fn execute(self, printer: &mut impl Printer, config: &TomlConfig) -> Result<()> {
match self {
Self::Account(cmd) => cmd.execute(printer, config).await,
Self::Folder(cmd) => cmd.execute(printer, config).await,
Self::Envelope(cmd) => cmd.execute(printer, config).await,
Self::Flag(cmd) => cmd.execute(printer, config).await,
Self::Message(cmd) => cmd.execute(printer, config).await,
Self::Attachment(cmd) => cmd.execute(printer, config).await,
Self::Template(cmd) => cmd.execute(printer, config).await,
Self::Manual(cmd) => cmd.execute(printer).await,
Self::Completion(cmd) => cmd.execute(printer).await,
}
}
}

View file

@ -1,39 +0,0 @@
//! Module related to completion CLI.
//!
//! This module provides subcommands and a command matcher related to completion.
use anyhow::Result;
use clap::{value_parser, Arg, ArgMatches, Command};
use clap_complete::Shell;
use log::debug;
const ARG_SHELL: &str = "shell";
const CMD_COMPLETION: &str = "completion";
type SomeShell = Shell;
/// Completion commands.
pub enum Cmd {
/// Generate completion script for the given shell.
Generate(SomeShell),
}
/// Completion command matcher.
pub fn matches(m: &ArgMatches) -> Result<Option<Cmd>> {
if let Some(m) = m.subcommand_matches(CMD_COMPLETION) {
let shell = m.get_one::<Shell>(ARG_SHELL).cloned().unwrap();
debug!("shell: {:?}", shell);
return Ok(Some(Cmd::Generate(shell)));
};
Ok(None)
}
/// Completion subcommands.
pub fn subcmd() -> Command {
Command::new(CMD_COMPLETION)
.about("Generates the completion script for the given shell")
.args(&[Arg::new(ARG_SHELL)
.value_parser(value_parser!(Shell))
.required(true)])
}

View file

@ -1,15 +0,0 @@
//! Module related to completion handling.
//!
//! This module gathers all completion commands.
use anyhow::Result;
use clap::Command;
use clap_complete::Shell;
use std::io::stdout;
/// Generates completion script from the given [`clap::App`] for the given shell slice.
pub fn generate<'a>(mut cmd: Command, shell: Shell) -> Result<()> {
let name = cmd.get_name().to_string();
clap_complete::generate(shell, &mut cmd, name, &mut stdout());
Ok(())
}

View file

@ -1,8 +0,0 @@
//! Module related to shell completion.
//!
//! This module allows users to generate autocompletion scripts for
//! their shells. You can see the list of available shells directly on
//! the clap's [docs.rs](https://docs.rs/clap/2.33.3/clap/enum.Shell.html).
pub mod args;
pub mod handlers;

36
src/completion/command.rs Normal file
View file

@ -0,0 +1,36 @@
use anyhow::Result;
use clap::{value_parser, CommandFactory, Parser};
use clap_complete::Shell;
use log::info;
use std::io;
use crate::{cli::Cli, printer::Printer};
/// Print completion script for a shell to stdout.
///
/// This command allows you to generate completion script for a given
/// shell. The script is printed to the standard output. If you want
/// to write it to a file, just use unix redirection.
#[derive(Debug, Parser)]
pub struct CompletionGenerateCommand {
/// Shell for which completion script should be generated for.
#[arg(value_parser = value_parser!(Shell))]
pub shell: Shell,
}
impl CompletionGenerateCommand {
pub async fn execute(self, printer: &mut impl Printer) -> Result<()> {
info!("executing completion generate command");
let mut cmd = Cli::command();
let name = cmd.get_name().to_string();
clap_complete::generate(self.shell, &mut cmd, name, &mut io::stdout());
printer.print(format!(
"Shell script successfully generated for shell {}!",
self.shell
))?;
Ok(())
}
}

1
src/completion/mod.rs Normal file
View file

@ -0,0 +1 @@
pub mod command;

View file

@ -6,16 +6,21 @@ const ARG_CONFIG: &str = "config";
/// Represents the config file path argument. This argument allows the
/// user to customize the config file path.
pub fn arg() -> Arg {
Arg::new(ARG_CONFIG)
.help("Set a custom configuration file path")
pub fn global_args() -> impl IntoIterator<Item = Arg> {
[Arg::new(ARG_CONFIG)
.help("Override the configuration file path")
.long_help(
"Override the configuration file path
If the file under the given path does not exist, the wizard will propose to create it.",
)
.long("config")
.short('c')
.global(true)
.value_name("PATH")
.value_name("path")]
}
/// Represents the config file path argument parser.
pub fn parse_arg(matches: &ArgMatches) -> Option<&str> {
pub fn parse_global_arg(matches: &ArgMatches) -> Option<&str> {
matches.get_one::<String>(ARG_CONFIG).map(String::as_str)
}

View file

@ -1,652 +0,0 @@
//! Deserialized config module.
//!
//! This module contains the raw deserialized representation of the
//! user configuration file.
use anyhow::{anyhow, Context, Result};
use dialoguer::Confirm;
use dirs::{config_dir, home_dir};
use email::{
account::AccountConfig,
email::{EmailHooks, EmailTextPlainFormat},
};
use log::{debug, trace};
use process::Cmd;
use serde::{Deserialize, Serialize};
use std::{collections::HashMap, fs, path::PathBuf, process::exit};
use toml;
use crate::{
account::DeserializedAccountConfig,
config::{prelude::*, wizard},
wizard_prompt, wizard_warn,
};
/// Represents the user config file.
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub struct DeserializedConfig {
#[serde(alias = "name")]
pub display_name: Option<String>,
pub signature_delim: Option<String>,
pub signature: Option<String>,
pub downloads_dir: Option<PathBuf>,
pub folder_listing_page_size: Option<usize>,
pub folder_aliases: Option<HashMap<String, String>>,
pub email_listing_page_size: Option<usize>,
pub email_listing_datetime_fmt: Option<String>,
pub email_listing_datetime_local_tz: Option<bool>,
pub email_reading_headers: Option<Vec<String>>,
#[serde(
default,
with = "EmailTextPlainFormatDef",
skip_serializing_if = "EmailTextPlainFormat::is_default"
)]
pub email_reading_format: EmailTextPlainFormat,
#[serde(
default,
with = "OptionCmdDef",
skip_serializing_if = "Option::is_none"
)]
pub email_reading_verify_cmd: Option<Cmd>,
#[serde(
default,
with = "OptionCmdDef",
skip_serializing_if = "Option::is_none"
)]
pub email_reading_decrypt_cmd: Option<Cmd>,
pub email_writing_headers: Option<Vec<String>>,
#[serde(
default,
with = "OptionCmdDef",
skip_serializing_if = "Option::is_none"
)]
pub email_writing_sign_cmd: Option<Cmd>,
#[serde(
default,
with = "OptionCmdDef",
skip_serializing_if = "Option::is_none"
)]
pub email_writing_encrypt_cmd: Option<Cmd>,
pub email_sending_save_copy: Option<bool>,
#[serde(
default,
with = "EmailHooksDef",
skip_serializing_if = "EmailHooks::is_empty"
)]
pub email_hooks: EmailHooks,
#[serde(flatten)]
pub accounts: HashMap<String, DeserializedAccountConfig>,
}
impl DeserializedConfig {
/// Tries to create a config from an optional path.
pub async fn from_opt_path(path: Option<&str>) -> Result<Self> {
debug!("path: {:?}", path);
let config = if let Some(path) = path.map(PathBuf::from).or_else(Self::path) {
let content = fs::read_to_string(path).context("cannot read config file")?;
toml::from_str(&content).context("cannot parse config file")?
} else {
wizard_warn!("Himalaya could not find an already existing configuration file.");
if !Confirm::new()
.with_prompt(wizard_prompt!(
"Would you like to create one with the wizard?"
))
.default(true)
.interact_opt()?
.unwrap_or_default()
{
exit(0);
}
wizard::configure().await?
};
if config.accounts.is_empty() {
return Err(anyhow!("config file must contain at least one account"));
}
trace!("config: {:#?}", config);
Ok(config)
}
/// Tries to return a config path from a few default settings.
///
/// Tries paths in this order:
///
/// - `"$XDG_CONFIG_DIR/himalaya/config.toml"` (or equivalent to `$XDG_CONFIG_DIR` in other
/// OSes.)
/// - `"$HOME/.config/himalaya/config.toml"`
/// - `"$HOME/.himalayarc"`
///
/// Returns `Some(path)` if the path exists, otherwise `None`.
pub fn path() -> Option<PathBuf> {
config_dir()
.map(|p| p.join("himalaya").join("config.toml"))
.filter(|p| p.exists())
.or_else(|| home_dir().map(|p| p.join(".config").join("himalaya").join("config.toml")))
.filter(|p| p.exists())
.or_else(|| home_dir().map(|p| p.join(".himalayarc")))
.filter(|p| p.exists())
}
pub fn to_account_config(&self, account_name: Option<&str>) -> Result<AccountConfig> {
let (account_name, deserialized_account_config) = match account_name {
Some("default") | Some("") | None => self
.accounts
.iter()
.find_map(|(name, account)| {
account
.default
.filter(|default| *default == true)
.map(|_| (name.clone(), account))
})
.ok_or_else(|| anyhow!("cannot find default account")),
Some(name) => self
.accounts
.get(name)
.map(|account| (name.to_string(), account))
.ok_or_else(|| anyhow!(format!("cannot find account {}", name))),
}?;
Ok(deserialized_account_config.to_account_config(account_name, self))
}
}
#[cfg(test)]
mod tests {
use email::{
account::PasswdConfig,
backend::{BackendConfig, MaildirConfig},
sender::{SenderConfig, SendmailConfig},
};
use secret::Secret;
#[cfg(feature = "notmuch-backend")]
use email::backend::NotmuchConfig;
#[cfg(feature = "imap-backend")]
use email::backend::{ImapAuthConfig, ImapConfig};
#[cfg(feature = "smtp-sender")]
use email::sender::{SmtpAuthConfig, SmtpConfig};
use std::io::Write;
use tempfile::NamedTempFile;
use super::*;
async fn make_config(config: &str) -> Result<DeserializedConfig> {
let mut file = NamedTempFile::new().unwrap();
write!(file, "{}", config).unwrap();
DeserializedConfig::from_opt_path(file.into_temp_path().to_str()).await
}
#[tokio::test]
async fn empty_config() {
let config = make_config("").await;
assert_eq!(
config.unwrap_err().root_cause().to_string(),
"config file must contain at least one account"
);
}
#[tokio::test]
async fn account_missing_email_field() {
let config = make_config("[account]").await;
assert!(config
.unwrap_err()
.root_cause()
.to_string()
.contains("missing field `email`"));
}
#[tokio::test]
async fn account_missing_backend_field() {
let config = make_config(
"[account]
email = \"test@localhost\"",
)
.await;
assert!(config
.unwrap_err()
.root_cause()
.to_string()
.contains("missing field `backend`"));
}
#[tokio::test]
async fn account_invalid_backend_field() {
let config = make_config(
"[account]
email = \"test@localhost\"
backend = \"bad\"",
)
.await;
assert!(config
.unwrap_err()
.root_cause()
.to_string()
.contains("unknown variant `bad`"));
}
#[tokio::test]
async fn imap_account_missing_host_field() {
let config = make_config(
"[account]
email = \"test@localhost\"
sender = \"none\"
backend = \"imap\"",
)
.await;
assert!(config
.unwrap_err()
.root_cause()
.to_string()
.contains("missing field `imap-host`"));
}
#[tokio::test]
async fn account_backend_imap_missing_port_field() {
let config = make_config(
"[account]
email = \"test@localhost\"
sender = \"none\"
backend = \"imap\"
imap-host = \"localhost\"",
)
.await;
assert!(config
.unwrap_err()
.root_cause()
.to_string()
.contains("missing field `imap-port`"));
}
#[tokio::test]
async fn account_backend_imap_missing_login_field() {
let config = make_config(
"[account]
email = \"test@localhost\"
sender = \"none\"
backend = \"imap\"
imap-host = \"localhost\"
imap-port = 993",
)
.await;
assert!(config
.unwrap_err()
.root_cause()
.to_string()
.contains("missing field `imap-login`"));
}
#[tokio::test]
async fn account_backend_imap_missing_passwd_cmd_field() {
let config = make_config(
"[account]
email = \"test@localhost\"
sender = \"none\"
backend = \"imap\"
imap-host = \"localhost\"
imap-port = 993
imap-login = \"login\"",
)
.await;
assert!(config
.unwrap_err()
.root_cause()
.to_string()
.contains("missing field `imap-auth`"));
}
#[tokio::test]
async fn account_backend_maildir_missing_root_dir_field() {
let config = make_config(
"[account]
email = \"test@localhost\"
sender = \"none\"
backend = \"maildir\"",
)
.await;
assert!(config
.unwrap_err()
.root_cause()
.to_string()
.contains("missing field `maildir-root-dir`"));
}
#[cfg(feature = "notmuch-backend")]
#[tokio::test]
async fn account_backend_notmuch_missing_db_path_field() {
let config = make_config(
"[account]
email = \"test@localhost\"
sender = \"none\"
backend = \"notmuch\"",
)
.await;
assert!(config
.unwrap_err()
.root_cause()
.to_string()
.contains("missing field `notmuch-db-path`"));
}
#[tokio::test]
async fn account_missing_sender_field() {
let config = make_config(
"[account]
email = \"test@localhost\"
backend = \"none\"",
)
.await;
assert!(config
.unwrap_err()
.root_cause()
.to_string()
.contains("missing field `sender`"));
}
#[tokio::test]
async fn account_invalid_sender_field() {
let config = make_config(
"[account]
email = \"test@localhost\"
backend = \"none\"
sender = \"bad\"",
)
.await;
assert!(config
.unwrap_err()
.root_cause()
.to_string()
.contains("unknown variant `bad`, expected one of `none`, `smtp`, `sendmail`"),);
}
#[tokio::test]
async fn account_smtp_sender_missing_host_field() {
let config = make_config(
"[account]
email = \"test@localhost\"
backend = \"none\"
sender = \"smtp\"",
)
.await;
assert!(config
.unwrap_err()
.root_cause()
.to_string()
.contains("missing field `smtp-host`"));
}
#[tokio::test]
async fn account_smtp_sender_missing_port_field() {
let config = make_config(
"[account]
email = \"test@localhost\"
backend = \"none\"
sender = \"smtp\"
smtp-host = \"localhost\"",
)
.await;
assert!(config
.unwrap_err()
.root_cause()
.to_string()
.contains("missing field `smtp-port`"));
}
#[tokio::test]
async fn account_smtp_sender_missing_login_field() {
let config = make_config(
"[account]
email = \"test@localhost\"
backend = \"none\"
sender = \"smtp\"
smtp-host = \"localhost\"
smtp-port = 25",
)
.await;
assert!(config
.unwrap_err()
.root_cause()
.to_string()
.contains("missing field `smtp-login`"));
}
#[tokio::test]
async fn account_smtp_sender_missing_auth_field() {
let config = make_config(
"[account]
email = \"test@localhost\"
backend = \"none\"
sender = \"smtp\"
smtp-host = \"localhost\"
smtp-port = 25
smtp-login = \"login\"",
)
.await;
assert!(config
.unwrap_err()
.root_cause()
.to_string()
.contains("missing field `smtp-auth`"));
}
#[tokio::test]
async fn account_sendmail_sender_missing_cmd_field() {
let config = make_config(
"[account]
email = \"test@localhost\"
backend = \"none\"
sender = \"sendmail\"",
)
.await;
assert_eq!(
config.unwrap(),
DeserializedConfig {
accounts: HashMap::from_iter([(
"account".into(),
DeserializedAccountConfig {
email: "test@localhost".into(),
sender: SenderConfig::Sendmail(SendmailConfig {
cmd: "/usr/sbin/sendmail".into()
}),
..DeserializedAccountConfig::default()
}
)]),
..DeserializedConfig::default()
}
)
}
#[cfg(feature = "smtp-sender")]
#[tokio::test]
async fn account_smtp_sender_minimum_config() {
use email::sender::SenderConfig;
let config = make_config(
"[account]
email = \"test@localhost\"
backend = \"none\"
sender = \"smtp\"
smtp-host = \"localhost\"
smtp-port = 25
smtp-login = \"login\"
smtp-auth = \"passwd\"
smtp-passwd = { cmd = \"echo password\" }",
)
.await;
assert_eq!(
config.unwrap(),
DeserializedConfig {
accounts: HashMap::from_iter([(
"account".into(),
DeserializedAccountConfig {
email: "test@localhost".into(),
sender: SenderConfig::Smtp(SmtpConfig {
host: "localhost".into(),
port: 25,
login: "login".into(),
auth: SmtpAuthConfig::Passwd(PasswdConfig {
passwd: Secret::new_cmd(String::from("echo password"))
}),
..SmtpConfig::default()
}),
..DeserializedAccountConfig::default()
}
)]),
..DeserializedConfig::default()
}
)
}
#[tokio::test]
async fn account_sendmail_sender_minimum_config() {
let config = make_config(
"[account]
email = \"test@localhost\"
backend = \"none\"
sender = \"sendmail\"
sendmail-cmd = \"echo send\"",
)
.await;
assert_eq!(
config.unwrap(),
DeserializedConfig {
accounts: HashMap::from_iter([(
"account".into(),
DeserializedAccountConfig {
email: "test@localhost".into(),
sender: SenderConfig::Sendmail(SendmailConfig {
cmd: Cmd::from("echo send")
}),
..DeserializedAccountConfig::default()
}
)]),
..DeserializedConfig::default()
}
)
}
#[tokio::test]
async fn account_backend_imap_minimum_config() {
let config = make_config(
"[account]
email = \"test@localhost\"
sender = \"none\"
backend = \"imap\"
imap-host = \"localhost\"
imap-port = 993
imap-login = \"login\"
imap-auth = \"passwd\"
imap-passwd = { cmd = \"echo password\" }",
)
.await;
assert_eq!(
config.unwrap(),
DeserializedConfig {
accounts: HashMap::from_iter([(
"account".into(),
DeserializedAccountConfig {
email: "test@localhost".into(),
backend: BackendConfig::Imap(ImapConfig {
host: "localhost".into(),
port: 993,
login: "login".into(),
auth: ImapAuthConfig::Passwd(PasswdConfig {
passwd: Secret::new_cmd(String::from("echo password"))
}),
..ImapConfig::default()
}),
..DeserializedAccountConfig::default()
}
)]),
..DeserializedConfig::default()
}
)
}
#[tokio::test]
async fn account_backend_maildir_minimum_config() {
let config = make_config(
"[account]
email = \"test@localhost\"
sender = \"none\"
backend = \"maildir\"
maildir-root-dir = \"/tmp/maildir\"",
)
.await;
assert_eq!(
config.unwrap(),
DeserializedConfig {
accounts: HashMap::from_iter([(
"account".into(),
DeserializedAccountConfig {
email: "test@localhost".into(),
backend: BackendConfig::Maildir(MaildirConfig {
root_dir: "/tmp/maildir".into(),
}),
..DeserializedAccountConfig::default()
}
)]),
..DeserializedConfig::default()
}
)
}
#[cfg(feature = "notmuch-backend")]
#[tokio::test]
async fn account_backend_notmuch_minimum_config() {
let config = make_config(
"[account]
email = \"test@localhost\"
sender = \"none\"
backend = \"notmuch\"
notmuch-db-path = \"/tmp/notmuch.db\"",
)
.await;
assert_eq!(
config.unwrap(),
DeserializedConfig {
accounts: HashMap::from_iter([(
"account".into(),
DeserializedAccountConfig {
email: "test@localhost".into(),
backend: BackendConfig::Notmuch(NotmuchConfig {
db_path: "/tmp/notmuch.db".into(),
}),
..DeserializedAccountConfig::default()
}
)]),
..DeserializedConfig::default()
}
);
}
}

View file

@ -1,6 +1,729 @@
pub mod args;
pub mod config;
pub mod prelude;
pub mod wizard;
pub use config::*;
use anyhow::{anyhow, Context, Result};
use dialoguer::Confirm;
use dirs::{config_dir, home_dir};
use email::{
account::config::AccountConfig, config::Config, envelope::config::EnvelopeConfig,
folder::config::FolderConfig, message::config::MessageConfig,
};
use serde::{Deserialize, Serialize};
use shellexpand_utils::{canonicalize, expand};
use std::{
collections::HashMap,
fs,
path::{Path, PathBuf},
process,
};
use toml;
use crate::{account::config::TomlAccountConfig, backend::BackendKind, wizard_prompt, wizard_warn};
/// Represents the user config file.
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub struct TomlConfig {
#[serde(alias = "name")]
pub display_name: Option<String>,
pub signature: Option<String>,
pub signature_delim: Option<String>,
pub downloads_dir: Option<PathBuf>,
#[serde(flatten)]
pub accounts: HashMap<String, TomlAccountConfig>,
}
impl TomlConfig {
/// Read and parse the TOML configuration at the given path.
///
/// Returns an error if the configuration file cannot be read or
/// if its content cannot be parsed.
fn from_path(path: &Path) -> Result<Self> {
let content =
fs::read_to_string(path).context(format!("cannot read config file at {path:?}"))?;
toml::from_str(&content).context(format!("cannot parse config file at {path:?}"))
}
/// Create and save a TOML configuration using the wizard.
///
/// If the user accepts the confirmation, the wizard starts and
/// help him to create his configuration file. Otherwise the
/// program stops.
///
/// NOTE: the wizard can only be used with interactive shells.
async fn from_wizard(path: PathBuf) -> Result<Self> {
wizard_warn!("Cannot find existing configuration at {path:?}.");
let confirm = Confirm::new()
.with_prompt(wizard_prompt!(
"Would you like to create one with the wizard?"
))
.default(true)
.interact_opt()?
.unwrap_or_default();
if !confirm {
process::exit(0);
}
wizard::configure(path).await
}
/// Read and parse the TOML configuration from default paths.
pub async fn from_default_paths() -> Result<Self> {
match Self::first_valid_default_path() {
Some(path) => Self::from_path(&path),
None => Self::from_wizard(Self::default_path()?).await,
}
}
/// Read and parse the TOML configuration at the optional given
/// path.
///
/// If the given path exists, then read and parse the TOML
/// configuration from it.
///
/// If the given path does not exist, then create it using the
/// wizard.
///
/// If no path is given, then either read and parse the TOML
/// configuration at the first valid default path, otherwise
/// create it using the wizard. wizard.
pub async fn from_some_path_or_default(path: Option<impl Into<PathBuf>>) -> Result<Self> {
match path.map(Into::into) {
Some(ref path) if path.exists() => Self::from_path(path),
Some(path) => Self::from_wizard(path).await,
None => Self::from_default_paths().await,
}
}
/// Get the default configuration path.
///
/// Returns an error if the XDG configuration directory cannot be
/// found.
pub fn default_path() -> Result<PathBuf> {
Ok(config_dir()
.ok_or(anyhow!("cannot get XDG config directory"))?
.join("himalaya")
.join("config.toml"))
}
/// Get the first default configuration path that points to a
/// valid file.
///
/// Tries paths in this order:
///
/// - `$XDG_CONFIG_DIR/himalaya/config.toml` (or equivalent to
/// `$XDG_CONFIG_DIR` in other OSes.)
/// - `$HOME/.config/himalaya/config.toml`
/// - `$HOME/.himalayarc`
pub fn first_valid_default_path() -> Option<PathBuf> {
Self::default_path()
.ok()
.filter(|p| p.exists())
.or_else(|| home_dir().map(|p| p.join(".config").join("himalaya").join("config.toml")))
.filter(|p| p.exists())
.or_else(|| home_dir().map(|p| p.join(".himalayarc")))
.filter(|p| p.exists())
}
pub fn into_toml_account_config(
&self,
account_name: Option<&str>,
) -> Result<(String, TomlAccountConfig)> {
let (account_name, mut toml_account_config) = match account_name {
Some("default") | Some("") | None => self
.accounts
.iter()
.find_map(|(name, account)| {
account
.default
.filter(|default| *default == true)
.map(|_| (name.to_owned(), account.clone()))
})
.ok_or_else(|| anyhow!("cannot find default account")),
Some(name) => self
.accounts
.get(name)
.map(|account| (name.to_owned(), account.clone()))
.ok_or_else(|| anyhow!("cannot find account {name}")),
}?;
#[cfg(feature = "imap")]
if let Some(imap_config) = toml_account_config.imap.as_mut() {
imap_config
.auth
.replace_undefined_keyring_entries(&account_name);
}
#[cfg(feature = "smtp")]
if let Some(smtp_config) = toml_account_config.smtp.as_mut() {
smtp_config
.auth
.replace_undefined_keyring_entries(&account_name);
}
Ok((account_name, toml_account_config))
}
/// Build account configurations from a given account name.
pub fn into_account_configs(
self,
account_name: Option<&str>,
disable_cache: bool,
) -> Result<(TomlAccountConfig, AccountConfig)> {
let (account_name, mut toml_account_config) =
self.into_toml_account_config(account_name)?;
if let Some(true) = toml_account_config.sync.as_ref().and_then(|c| c.enable) {
if !disable_cache {
toml_account_config.backend = Some(BackendKind::MaildirForSync);
}
}
let config = Config {
display_name: self.display_name,
signature: self.signature,
signature_delim: self.signature_delim,
downloads_dir: self.downloads_dir,
accounts: HashMap::from_iter(self.accounts.clone().into_iter().map(
|(name, config)| {
(
name.clone(),
AccountConfig {
name,
email: config.email,
display_name: config.display_name,
signature: config.signature,
signature_delim: config.signature_delim,
downloads_dir: config.downloads_dir,
folder: config.folder.map(|c| FolderConfig {
aliases: c.alias,
list: c.list.map(|c| c.remote),
}),
envelope: config.envelope.map(|c| EnvelopeConfig {
list: c.list.map(|c| c.remote),
}),
message: config.message.map(|c| MessageConfig {
read: c.read.map(|c| c.remote),
write: c.write.map(|c| c.remote),
send: c.send.map(|c| c.remote),
}),
sync: config.sync,
#[cfg(feature = "pgp")]
pgp: config.pgp,
},
)
},
)),
};
let account_config = config.account(&account_name)?;
Ok((toml_account_config, account_config))
}
}
/// Parse a configuration file path as [`PathBuf`].
///
/// The path is shell-expanded then canonicalized (if applicable).
pub fn path_parser(path: &str) -> Result<PathBuf, String> {
expand::try_path(path)
.map(canonicalize::path)
.map_err(|err| err.to_string())
}
#[cfg(test)]
mod tests {
use email::{
account::config::passwd::PasswdConfig, maildir::config::MaildirConfig,
sendmail::config::SendmailConfig,
};
use secret::Secret;
#[cfg(feature = "notmuch")]
use email::backend::NotmuchConfig;
#[cfg(feature = "imap")]
use email::imap::config::{ImapAuthConfig, ImapConfig};
#[cfg(feature = "smtp")]
use email::smtp::config::{SmtpAuthConfig, SmtpConfig};
use std::io::Write;
use tempfile::NamedTempFile;
use super::*;
async fn make_config(config: &str) -> Result<TomlConfig> {
let mut file = NamedTempFile::new().unwrap();
write!(file, "{}", config).unwrap();
TomlConfig::from_some_path_or_default(file.into_temp_path().to_str()).await
}
#[tokio::test]
async fn empty_config() {
let config = make_config("").await;
assert_eq!(
config.unwrap_err().root_cause().to_string(),
"config file must contain at least one account"
);
}
#[tokio::test]
async fn account_missing_email_field() {
let config = make_config("[account]").await;
assert!(config
.unwrap_err()
.root_cause()
.to_string()
.contains("missing field `email`"));
}
#[tokio::test]
async fn account_missing_backend_field() {
let config = make_config(
"[account]
email = \"test@localhost\"",
)
.await;
assert!(config
.unwrap_err()
.root_cause()
.to_string()
.contains("missing field `backend`"));
}
#[tokio::test]
async fn account_invalid_backend_field() {
let config = make_config(
"[account]
email = \"test@localhost\"
backend = \"bad\"",
)
.await;
assert!(config
.unwrap_err()
.root_cause()
.to_string()
.contains("unknown variant `bad`"));
}
#[tokio::test]
async fn imap_account_missing_host_field() {
let config = make_config(
"[account]
email = \"test@localhost\"
sender = \"none\"
backend = \"imap\"",
)
.await;
assert!(config
.unwrap_err()
.root_cause()
.to_string()
.contains("missing field `imap-host`"));
}
#[tokio::test]
async fn account_backend_imap_missing_port_field() {
let config = make_config(
"[account]
email = \"test@localhost\"
sender = \"none\"
backend = \"imap\"
imap-host = \"localhost\"",
)
.await;
assert!(config
.unwrap_err()
.root_cause()
.to_string()
.contains("missing field `imap-port`"));
}
#[tokio::test]
async fn account_backend_imap_missing_login_field() {
let config = make_config(
"[account]
email = \"test@localhost\"
sender = \"none\"
backend = \"imap\"
imap-host = \"localhost\"
imap-port = 993",
)
.await;
assert!(config
.unwrap_err()
.root_cause()
.to_string()
.contains("missing field `imap-login`"));
}
#[tokio::test]
async fn account_backend_imap_missing_passwd_cmd_field() {
let config = make_config(
"[account]
email = \"test@localhost\"
sender = \"none\"
backend = \"imap\"
imap-host = \"localhost\"
imap-port = 993
imap-login = \"login\"",
)
.await;
assert!(config
.unwrap_err()
.root_cause()
.to_string()
.contains("missing field `imap-auth`"));
}
#[tokio::test]
async fn account_backend_maildir_missing_root_dir_field() {
let config = make_config(
"[account]
email = \"test@localhost\"
sender = \"none\"
backend = \"maildir\"",
)
.await;
assert!(config
.unwrap_err()
.root_cause()
.to_string()
.contains("missing field `maildir-root-dir`"));
}
#[cfg(feature = "notmuch")]
#[tokio::test]
async fn account_backend_notmuch_missing_db_path_field() {
let config = make_config(
"[account]
email = \"test@localhost\"
sender = \"none\"
backend = \"notmuch\"",
)
.await;
assert!(config
.unwrap_err()
.root_cause()
.to_string()
.contains("missing field `notmuch-db-path`"));
}
#[tokio::test]
async fn account_missing_sender_field() {
let config = make_config(
"[account]
email = \"test@localhost\"
backend = \"none\"",
)
.await;
assert!(config
.unwrap_err()
.root_cause()
.to_string()
.contains("missing field `sender`"));
}
#[tokio::test]
async fn account_invalid_sender_field() {
let config = make_config(
"[account]
email = \"test@localhost\"
backend = \"none\"
sender = \"bad\"",
)
.await;
assert!(config
.unwrap_err()
.root_cause()
.to_string()
.contains("unknown variant `bad`, expected one of `none`, `smtp`, `sendmail`"),);
}
#[tokio::test]
async fn account_smtp_sender_missing_host_field() {
let config = make_config(
"[account]
email = \"test@localhost\"
backend = \"none\"
sender = \"smtp\"",
)
.await;
assert!(config
.unwrap_err()
.root_cause()
.to_string()
.contains("missing field `smtp-host`"));
}
#[tokio::test]
async fn account_smtp_sender_missing_port_field() {
let config = make_config(
"[account]
email = \"test@localhost\"
backend = \"none\"
sender = \"smtp\"
smtp-host = \"localhost\"",
)
.await;
assert!(config
.unwrap_err()
.root_cause()
.to_string()
.contains("missing field `smtp-port`"));
}
#[tokio::test]
async fn account_smtp_sender_missing_login_field() {
let config = make_config(
"[account]
email = \"test@localhost\"
backend = \"none\"
sender = \"smtp\"
smtp-host = \"localhost\"
smtp-port = 25",
)
.await;
assert!(config
.unwrap_err()
.root_cause()
.to_string()
.contains("missing field `smtp-login`"));
}
#[tokio::test]
async fn account_smtp_sender_missing_auth_field() {
let config = make_config(
"[account]
email = \"test@localhost\"
backend = \"none\"
sender = \"smtp\"
smtp-host = \"localhost\"
smtp-port = 25
smtp-login = \"login\"",
)
.await;
assert!(config
.unwrap_err()
.root_cause()
.to_string()
.contains("missing field `smtp-auth`"));
}
#[tokio::test]
async fn account_sendmail_sender_missing_cmd_field() {
let config = make_config(
"[account]
email = \"test@localhost\"
backend = \"none\"
sender = \"sendmail\"",
)
.await;
assert_eq!(
config.unwrap(),
TomlConfig {
accounts: HashMap::from_iter([(
"account".into(),
TomlAccountConfig {
email: "test@localhost".into(),
sender: SenderConfig::Sendmail(SendmailConfig {
cmd: "/usr/sbin/sendmail".into()
}),
..TomlAccountConfig::default()
}
)]),
..TomlConfig::default()
}
)
}
#[cfg(feature = "smtp")]
#[tokio::test]
async fn account_smtp_sender_minimum_config() {
use email::sender::SenderConfig;
let config = make_config(
"[account]
email = \"test@localhost\"
backend = \"none\"
sender = \"smtp\"
smtp-host = \"localhost\"
smtp-port = 25
smtp-login = \"login\"
smtp-auth = \"passwd\"
smtp-passwd = { cmd = \"echo password\" }",
)
.await;
assert_eq!(
config.unwrap(),
TomlConfig {
accounts: HashMap::from_iter([(
"account".into(),
TomlAccountConfig {
email: "test@localhost".into(),
sender: SenderConfig::Smtp(SmtpConfig {
host: "localhost".into(),
port: 25,
login: "login".into(),
auth: SmtpAuthConfig::Passwd(PasswdConfig {
passwd: Secret::new_cmd(String::from("echo password"))
}),
..SmtpConfig::default()
}),
..TomlAccountConfig::default()
}
)]),
..TomlConfig::default()
}
)
}
#[tokio::test]
async fn account_sendmail_sender_minimum_config() {
let config = make_config(
"[account]
email = \"test@localhost\"
backend = \"none\"
sender = \"sendmail\"
sendmail-cmd = \"echo send\"",
)
.await;
assert_eq!(
config.unwrap(),
TomlConfig {
accounts: HashMap::from_iter([(
"account".into(),
TomlAccountConfig {
email: "test@localhost".into(),
sender: SenderConfig::Sendmail(SendmailConfig {
cmd: Cmd::from("echo send")
}),
..TomlAccountConfig::default()
}
)]),
..TomlConfig::default()
}
)
}
#[tokio::test]
async fn account_backend_imap_minimum_config() {
let config = make_config(
"[account]
email = \"test@localhost\"
sender = \"none\"
backend = \"imap\"
imap-host = \"localhost\"
imap-port = 993
imap-login = \"login\"
imap-auth = \"passwd\"
imap-passwd = { cmd = \"echo password\" }",
)
.await;
assert_eq!(
config.unwrap(),
TomlConfig {
accounts: HashMap::from_iter([(
"account".into(),
TomlAccountConfig {
email: "test@localhost".into(),
backend: BackendConfig::Imap(ImapConfig {
host: "localhost".into(),
port: 993,
login: "login".into(),
auth: ImapAuthConfig::Passwd(PasswdConfig {
passwd: Secret::new_cmd(String::from("echo password"))
}),
..ImapConfig::default()
}),
..TomlAccountConfig::default()
}
)]),
..TomlConfig::default()
}
)
}
#[tokio::test]
async fn account_backend_maildir_minimum_config() {
let config = make_config(
"[account]
email = \"test@localhost\"
sender = \"none\"
backend = \"maildir\"
maildir-root-dir = \"/tmp/maildir\"",
)
.await;
assert_eq!(
config.unwrap(),
TomlConfig {
accounts: HashMap::from_iter([(
"account".into(),
TomlAccountConfig {
email: "test@localhost".into(),
backend: BackendConfig::Maildir(MaildirConfig {
root_dir: "/tmp/maildir".into(),
}),
..TomlAccountConfig::default()
}
)]),
..TomlConfig::default()
}
)
}
#[cfg(feature = "notmuch")]
#[tokio::test]
async fn account_backend_notmuch_minimum_config() {
let config = make_config(
"[account]
email = \"test@localhost\"
sender = \"none\"
backend = \"notmuch\"
notmuch-db-path = \"/tmp/notmuch.db\"",
)
.await;
assert_eq!(
config.unwrap(),
TomlConfig {
accounts: HashMap::from_iter([(
"account".into(),
TomlAccountConfig {
email: "test@localhost".into(),
backend: BackendConfig::Notmuch(NotmuchConfig {
db_path: "/tmp/notmuch.db".into(),
}),
..TomlAccountConfig::default()
}
)]),
..TomlConfig::default()
}
);
}
}

View file

@ -6,18 +6,21 @@ use email::account::GpgConfig;
use email::account::PgpConfig;
#[cfg(feature = "pgp-native")]
use email::account::{NativePgpConfig, NativePgpSecretKey, SignedSecretKey};
#[cfg(feature = "notmuch-backend")]
#[cfg(feature = "notmuch")]
use email::backend::NotmuchConfig;
#[cfg(feature = "imap-backend")]
use email::backend::{ImapAuthConfig, ImapConfig};
#[cfg(feature = "smtp-sender")]
use email::sender::{SmtpAuthConfig, SmtpConfig};
#[cfg(feature = "imap")]
use email::imap::config::{ImapAuthConfig, ImapConfig};
#[cfg(feature = "smtp")]
use email::smtp::config::{SmtpAuthConfig, SmtpConfig};
use email::{
account::{OAuth2Config, OAuth2Method, OAuth2Scopes, PasswdConfig},
backend::{BackendConfig, MaildirConfig},
email::{EmailHooks, EmailTextPlainFormat},
account::config::{
oauth2::{OAuth2Config, OAuth2Method, OAuth2Scopes},
passwd::PasswdConfig,
},
email::config::{EmailHooks, EmailTextPlainFormat},
folder::sync::FolderSyncStrategy,
sender::{SenderConfig, SendmailConfig},
maildir::config::MaildirConfig,
sendmail::config::SendmailConfig,
};
use keyring::Entry;
use process::{Cmd, Pipeline, SingleCmd};
@ -61,27 +64,38 @@ pub enum CmdDef {
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[serde(remote = "Option<Cmd>", from = "OptionCmd")]
#[serde(remote = "Option<Cmd>", from = "OptionCmd", into = "OptionCmd")]
pub struct OptionCmdDef;
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum OptionCmd {
#[default]
#[serde(skip_serializing)]
None,
#[serde(with = "SingleCmdDef")]
SingleCmd(SingleCmd),
#[serde(with = "PipelineDef")]
Pipeline(Pipeline),
pub struct OptionCmd {
#[serde(default, skip)]
is_some: bool,
#[serde(flatten, with = "CmdDef")]
inner: Cmd,
}
impl From<OptionCmd> for Option<Cmd> {
fn from(cmd: OptionCmd) -> Option<Cmd> {
match cmd {
OptionCmd::None => None,
OptionCmd::SingleCmd(cmd) => Some(Cmd::SingleCmd(cmd)),
OptionCmd::Pipeline(pipeline) => Some(Cmd::Pipeline(pipeline)),
if cmd.is_some {
Some(cmd.inner)
} else {
None
}
}
}
impl Into<OptionCmd> for Option<Cmd> {
fn into(self) -> OptionCmd {
match self {
Some(cmd) => OptionCmd {
is_some: true,
inner: cmd,
},
None => OptionCmd {
is_some: false,
inner: Default::default(),
},
}
}
}
@ -108,49 +122,66 @@ pub enum OAuth2MethodDef {
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[serde(remote = "BackendConfig", tag = "backend", rename_all = "kebab-case")]
pub enum BackendConfigDef {
#[default]
None,
#[cfg(feature = "imap-backend")]
#[serde(with = "ImapConfigDef")]
Imap(ImapConfig),
#[serde(with = "MaildirConfigDef")]
Maildir(MaildirConfig),
#[cfg(feature = "notmuch-backend")]
#[serde(with = "NotmuchConfigDef")]
Notmuch(NotmuchConfig),
#[serde(
remote = "Option<ImapConfig>",
from = "OptionImapConfig",
into = "OptionImapConfig"
)]
pub struct OptionImapConfigDef;
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
pub struct OptionImapConfig {
#[serde(default, skip)]
is_none: bool,
#[serde(flatten, with = "ImapConfigDef")]
inner: ImapConfig,
}
#[cfg(feature = "imap-backend")]
impl From<OptionImapConfig> for Option<ImapConfig> {
fn from(config: OptionImapConfig) -> Option<ImapConfig> {
if config.is_none {
None
} else {
Some(config.inner)
}
}
}
impl Into<OptionImapConfig> for Option<ImapConfig> {
fn into(self) -> OptionImapConfig {
match self {
Some(config) => OptionImapConfig {
is_none: false,
inner: config,
},
None => OptionImapConfig {
is_none: true,
inner: Default::default(),
},
}
}
}
#[cfg(feature = "imap")]
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(remote = "ImapConfig")]
#[serde(remote = "ImapConfig", rename_all = "kebab-case")]
pub struct ImapConfigDef {
#[serde(rename = "imap-host")]
pub host: String,
#[serde(rename = "imap-port")]
pub port: u16,
#[serde(rename = "imap-ssl")]
pub ssl: Option<bool>,
#[serde(rename = "imap-starttls")]
pub starttls: Option<bool>,
#[serde(rename = "imap-insecure")]
pub insecure: Option<bool>,
#[serde(rename = "imap-login")]
pub login: String,
#[serde(flatten, with = "ImapAuthConfigDef")]
pub auth: ImapAuthConfig,
#[serde(rename = "imap-notify-cmd")]
pub notify_cmd: Option<String>,
#[serde(rename = "imap-notify-query")]
pub notify_query: Option<String>,
#[serde(rename = "imap-watch-cmds")]
pub watch_cmds: Option<Vec<String>>,
}
#[cfg(feature = "imap-backend")]
#[cfg(feature = "imap")]
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(remote = "ImapAuthConfig", tag = "imap-auth")]
#[serde(remote = "ImapAuthConfig", tag = "auth")]
pub enum ImapAuthConfigDef {
#[serde(rename = "passwd", alias = "password", with = "ImapPasswdConfigDef")]
Passwd(#[serde(default)] PasswdConfig),
@ -162,7 +193,7 @@ pub enum ImapAuthConfigDef {
#[serde(remote = "PasswdConfig")]
pub struct ImapPasswdConfigDef {
#[serde(
rename = "imap-passwd",
rename = "passwd",
with = "SecretDef",
default,
skip_serializing_if = "Secret::is_undefined"
@ -227,6 +258,47 @@ pub enum ImapOAuth2ScopesDef {
Scopes(Vec<String>),
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[serde(
remote = "Option<MaildirConfig>",
from = "OptionMaildirConfig",
into = "OptionMaildirConfig"
)]
pub struct OptionMaildirConfigDef;
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
pub struct OptionMaildirConfig {
#[serde(default, skip)]
is_none: bool,
#[serde(flatten, with = "MaildirConfigDef")]
inner: MaildirConfig,
}
impl From<OptionMaildirConfig> for Option<MaildirConfig> {
fn from(config: OptionMaildirConfig) -> Option<MaildirConfig> {
if config.is_none {
None
} else {
Some(config.inner)
}
}
}
impl Into<OptionMaildirConfig> for Option<MaildirConfig> {
fn into(self) -> OptionMaildirConfig {
match self {
Some(config) => OptionMaildirConfig {
is_none: false,
inner: config,
},
None => OptionMaildirConfig {
is_none: true,
inner: Default::default(),
},
}
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[serde(remote = "MaildirConfig", rename_all = "kebab-case")]
pub struct MaildirConfigDef {
@ -234,7 +306,52 @@ pub struct MaildirConfigDef {
pub root_dir: PathBuf,
}
#[cfg(feature = "notmuch-backend")]
#[cfg(feature = "notmuch")]
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[serde(
remote = "Option<NotmuchConfig>",
from = "OptionNotmuchConfig",
into = "OptionNotmuchConfig"
)]
pub struct OptionNotmuchConfigDef;
#[cfg(feature = "notmuch")]
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
pub struct OptionNotmuchConfig {
#[serde(default, skip)]
is_none: bool,
#[serde(flatten, with = "NotmuchConfigDef")]
inner: NotmuchConfig,
}
#[cfg(feature = "notmuch")]
impl From<OptionNotmuchConfig> for Option<NotmuchConfig> {
fn from(config: OptionNotmuchConfig) -> Option<NotmuchConfig> {
if config.is_none {
None
} else {
Some(config.inner)
}
}
}
#[cfg(feature = "notmuch")]
impl Into<OptionNotmuchConfig> for Option<NotmuchConfig> {
fn into(self) -> OptionNotmuchConfig {
match self {
Some(config) => OptionNotmuchConfig {
is_none: false,
inner: config,
},
None => OptionNotmuchConfig {
is_none: true,
inner: Default::default(),
},
}
}
}
#[cfg(feature = "notmuch")]
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[serde(remote = "NotmuchConfig", rename_all = "kebab-case")]
pub struct NotmuchConfigDef {
@ -242,6 +359,47 @@ pub struct NotmuchConfigDef {
pub db_path: PathBuf,
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[serde(
remote = "Option<EmailTextPlainFormat>",
from = "OptionEmailTextPlainFormat",
into = "OptionEmailTextPlainFormat"
)]
pub struct OptionEmailTextPlainFormatDef;
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
pub struct OptionEmailTextPlainFormat {
#[serde(default, skip)]
is_none: bool,
#[serde(flatten, with = "EmailTextPlainFormatDef")]
inner: EmailTextPlainFormat,
}
impl From<OptionEmailTextPlainFormat> for Option<EmailTextPlainFormat> {
fn from(fmt: OptionEmailTextPlainFormat) -> Option<EmailTextPlainFormat> {
if fmt.is_none {
None
} else {
Some(fmt.inner)
}
}
}
impl Into<OptionEmailTextPlainFormat> for Option<EmailTextPlainFormat> {
fn into(self) -> OptionEmailTextPlainFormat {
match self {
Some(config) => OptionEmailTextPlainFormat {
is_none: false,
inner: config,
},
None => OptionEmailTextPlainFormat {
is_none: true,
inner: Default::default(),
},
}
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[serde(
remote = "EmailTextPlainFormat",
@ -257,40 +415,63 @@ pub enum EmailTextPlainFormatDef {
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[serde(remote = "SenderConfig", tag = "sender", rename_all = "kebab-case")]
pub enum SenderConfigDef {
#[default]
None,
#[cfg(feature = "smtp-sender")]
#[serde(with = "SmtpConfigDef")]
Smtp(SmtpConfig),
#[serde(with = "SendmailConfigDef")]
Sendmail(SendmailConfig),
#[serde(
remote = "Option<SmtpConfig>",
from = "OptionSmtpConfig",
into = "OptionSmtpConfig"
)]
pub struct OptionSmtpConfigDef;
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
pub struct OptionSmtpConfig {
#[serde(default, skip)]
is_none: bool,
#[serde(flatten, with = "SmtpConfigDef")]
inner: SmtpConfig,
}
#[cfg(feature = "smtp-sender")]
impl From<OptionSmtpConfig> for Option<SmtpConfig> {
fn from(config: OptionSmtpConfig) -> Option<SmtpConfig> {
if config.is_none {
None
} else {
Some(config.inner)
}
}
}
impl Into<OptionSmtpConfig> for Option<SmtpConfig> {
fn into(self) -> OptionSmtpConfig {
match self {
Some(config) => OptionSmtpConfig {
is_none: false,
inner: config,
},
None => OptionSmtpConfig {
is_none: true,
inner: Default::default(),
},
}
}
}
#[cfg(feature = "smtp")]
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(remote = "SmtpConfig")]
struct SmtpConfigDef {
#[serde(rename = "smtp-host")]
pub host: String,
#[serde(rename = "smtp-port")]
pub port: u16,
#[serde(rename = "smtp-ssl")]
pub ssl: Option<bool>,
#[serde(rename = "smtp-starttls")]
pub starttls: Option<bool>,
#[serde(rename = "smtp-insecure")]
pub insecure: Option<bool>,
#[serde(rename = "smtp-login")]
pub login: String,
#[serde(flatten, with = "SmtpAuthConfigDef")]
pub auth: SmtpAuthConfig,
}
#[cfg(feature = "smtp-sender")]
#[cfg(feature = "smtp")]
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(remote = "SmtpAuthConfig", tag = "smtp-auth")]
#[serde(remote = "SmtpAuthConfig", tag = "auth")]
pub enum SmtpAuthConfigDef {
#[serde(rename = "passwd", alias = "password", with = "SmtpPasswdConfigDef")]
Passwd(#[serde(default)] PasswdConfig),
@ -302,7 +483,7 @@ pub enum SmtpAuthConfigDef {
#[serde(remote = "PasswdConfig", default)]
pub struct SmtpPasswdConfigDef {
#[serde(
rename = "smtp-passwd",
rename = "passwd",
with = "SecretDef",
default,
skip_serializing_if = "Secret::is_undefined"
@ -311,32 +492,26 @@ pub struct SmtpPasswdConfigDef {
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(remote = "OAuth2Config")]
#[serde(remote = "OAuth2Config", rename_all = "kebab-case")]
pub struct SmtpOAuth2ConfigDef {
#[serde(rename = "smtp-oauth2-method", with = "OAuth2MethodDef", default)]
#[serde(with = "OAuth2MethodDef", default)]
pub method: OAuth2Method,
#[serde(rename = "smtp-oauth2-client-id")]
pub client_id: String,
#[serde(
rename = "smtp-oauth2-client-secret",
with = "SecretDef",
default,
skip_serializing_if = "Secret::is_undefined"
)]
pub client_secret: Secret,
#[serde(rename = "smtp-oauth2-auth-url")]
pub auth_url: String,
#[serde(rename = "smtp-oauth2-token-url")]
pub token_url: String,
#[serde(
rename = "smtp-oauth2-access-token",
with = "SecretDef",
default,
skip_serializing_if = "Secret::is_undefined"
)]
pub access_token: Secret,
#[serde(
rename = "smtp-oauth2-refresh-token",
with = "SecretDef",
default,
skip_serializing_if = "Secret::is_undefined"
@ -344,17 +519,11 @@ pub struct SmtpOAuth2ConfigDef {
pub refresh_token: Secret,
#[serde(flatten, with = "SmtpOAuth2ScopesDef")]
pub scopes: OAuth2Scopes,
#[serde(rename = "smtp-oauth2-pkce", default)]
#[serde(default)]
pub pkce: bool,
#[serde(
rename = "imap-oauth2-redirect-host",
default = "OAuth2Config::default_redirect_host"
)]
#[serde(default = "OAuth2Config::default_redirect_host")]
pub redirect_host: String,
#[serde(
rename = "imap-oauth2-redirect-port",
default = "OAuth2Config::default_redirect_port"
)]
#[serde(default = "OAuth2Config::default_redirect_port")]
pub redirect_port: u16,
}
@ -367,14 +536,51 @@ pub enum SmtpOAuth2ScopesDef {
Scopes(Vec<String>),
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[serde(
remote = "Option<SendmailConfig>",
from = "OptionSendmailConfig",
into = "OptionSendmailConfig"
)]
pub struct OptionSendmailConfigDef;
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
pub struct OptionSendmailConfig {
#[serde(default, skip)]
is_none: bool,
#[serde(flatten, with = "SendmailConfigDef")]
inner: SendmailConfig,
}
impl From<OptionSendmailConfig> for Option<SendmailConfig> {
fn from(config: OptionSendmailConfig) -> Option<SendmailConfig> {
if config.is_none {
None
} else {
Some(config.inner)
}
}
}
impl Into<OptionSendmailConfig> for Option<SendmailConfig> {
fn into(self) -> OptionSendmailConfig {
match self {
Some(config) => OptionSendmailConfig {
is_none: false,
inner: config,
},
None => OptionSendmailConfig {
is_none: true,
inner: Default::default(),
},
}
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(remote = "SendmailConfig", rename_all = "kebab-case")]
pub struct SendmailConfigDef {
#[serde(
rename = "sendmail-cmd",
with = "CmdDef",
default = "sendmail_default_cmd"
)]
#[serde(with = "CmdDef", default = "sendmail_default_cmd")]
cmd: Cmd,
}
@ -382,6 +588,51 @@ fn sendmail_default_cmd() -> Cmd {
Cmd::from("/usr/sbin/sendmail")
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[serde(
remote = "Option<EmailHooks>",
from = "OptionEmailHooks",
into = "OptionEmailHooks"
)]
pub struct OptionEmailHooksDef;
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
pub struct OptionEmailHooks {
#[serde(default, skip)]
is_none: bool,
#[serde(
flatten,
skip_serializing_if = "EmailHooks::is_empty",
with = "EmailHooksDef"
)]
inner: EmailHooks,
}
impl From<OptionEmailHooks> for Option<EmailHooks> {
fn from(hooks: OptionEmailHooks) -> Option<EmailHooks> {
if hooks.is_none {
None
} else {
Some(hooks.inner)
}
}
}
impl Into<OptionEmailHooks> for Option<EmailHooks> {
fn into(self) -> OptionEmailHooks {
match self {
Some(hooks) => OptionEmailHooks {
is_none: false,
inner: hooks,
},
None => OptionEmailHooks {
is_none: true,
inner: Default::default(),
},
}
}
}
/// Represents the email hooks. Useful for doing extra email
/// processing before or after sending it.
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
@ -392,6 +643,51 @@ pub struct EmailHooksDef {
pub pre_send: Option<Cmd>,
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[serde(
remote = "Option<FolderSyncStrategy>",
from = "OptionFolderSyncStrategy",
into = "OptionFolderSyncStrategy"
)]
pub struct OptionFolderSyncStrategyDef;
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
pub struct OptionFolderSyncStrategy {
#[serde(default, skip)]
is_some: bool,
#[serde(
flatten,
skip_serializing_if = "FolderSyncStrategy::is_default",
with = "FolderSyncStrategyDef"
)]
inner: FolderSyncStrategy,
}
impl From<OptionFolderSyncStrategy> for Option<FolderSyncStrategy> {
fn from(option: OptionFolderSyncStrategy) -> Option<FolderSyncStrategy> {
if option.is_some {
Some(option.inner)
} else {
None
}
}
}
impl Into<OptionFolderSyncStrategy> for Option<FolderSyncStrategy> {
fn into(self) -> OptionFolderSyncStrategy {
match self {
Some(strategy) => OptionFolderSyncStrategy {
is_some: true,
inner: strategy,
},
None => OptionFolderSyncStrategy {
is_some: false,
inner: Default::default(),
},
}
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[serde(remote = "FolderSyncStrategy", rename_all = "kebab-case")]
pub enum FolderSyncStrategyDef {
@ -406,10 +702,33 @@ pub enum FolderSyncStrategyDef {
#[cfg(feature = "pgp")]
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[serde(remote = "Option<PgpConfig>", from = "OptionPgpConfig")]
pub struct OptionPgpConfigDef;
#[cfg(feature = "pgp")]
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct OptionPgpConfig {
#[serde(default, skip)]
is_none: bool,
#[serde(flatten, with = "PgpConfigDef")]
inner: PgpConfig,
}
#[cfg(feature = "pgp")]
impl From<OptionPgpConfig> for Option<PgpConfig> {
fn from(config: OptionPgpConfig) -> Option<PgpConfig> {
if config.is_none {
None
} else {
Some(config.inner)
}
}
}
#[cfg(feature = "pgp")]
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(remote = "PgpConfig", tag = "backend", rename_all = "kebab-case")]
pub enum PgpConfigDef {
#[default]
None,
#[cfg(feature = "pgp-commands")]
#[serde(with = "CmdsPgpConfigDef", alias = "commands")]
Cmds(CmdsPgpConfig),

View file

@ -1,10 +1,13 @@
use super::DeserializedConfig;
use crate::account;
use anyhow::Result;
use dialoguer::{theme::ColorfulTheme, Confirm, Input, Password, Select};
use once_cell::sync::Lazy;
use shellexpand_utils::{shellexpand_path, try_shellexpand_path};
use std::{env, fs, io, process};
use shellexpand_utils::expand;
use std::{fs, io, path::PathBuf, process};
use toml_edit::{Document, Item};
use crate::account;
use super::TomlConfig;
#[macro_export]
macro_rules! wizard_warn {
@ -31,10 +34,10 @@ macro_rules! wizard_log {
pub(crate) static THEME: Lazy<ColorfulTheme> = Lazy::new(ColorfulTheme::default);
pub(crate) async fn configure() -> Result<DeserializedConfig> {
pub(crate) async fn configure(path: PathBuf) -> Result<TomlConfig> {
wizard_log!("Configuring your first account:");
let mut config = DeserializedConfig::default();
let mut config = TomlConfig::default();
while let Some((name, account_config)) = account::wizard::configure().await? {
config.accounts.insert(name, account_config);
@ -57,7 +60,10 @@ pub(crate) async fn configure() -> Result<DeserializedConfig> {
// accounts are setup, decide which will be the default. If no
// accounts are setup, exit the process.
let default_account = match config.accounts.len() {
0 => process::exit(0),
0 => {
wizard_warn!("No account configured, exiting.");
process::exit(0);
}
1 => Some(config.accounts.values_mut().next().unwrap()),
_ => {
let accounts = config.accounts.clone();
@ -86,25 +92,86 @@ pub(crate) async fn configure() -> Result<DeserializedConfig> {
.with_prompt(wizard_prompt!(
"Where would you like to save your configuration?"
))
.default(
dirs::config_dir()
.map(|p| p.join("himalaya").join("config.toml"))
.unwrap_or_else(|| env::temp_dir().join("himalaya").join("config.toml"))
.to_string_lossy()
.to_string(),
)
.validate_with(|path: &String| try_shellexpand_path(path).map(|_| ()))
.default(path.to_string_lossy().to_string())
.interact()?;
let path = shellexpand_path(&path);
let path = expand::path(&path);
println!("Writing the configuration to {path:?}");
let mut doc = toml::to_string(&config)?.parse::<Document>()?;
doc.iter_mut().for_each(|(_, item)| {
set_table_dotted(item, "folder-aliases");
set_table_dotted(item, "sync-folders-strategy");
set_table_dotted(item, "folder");
get_table_mut(item, "folder").map(|item| {
set_tables_dotted(item, ["add", "list", "expunge", "purge", "delete"]);
});
set_table_dotted(item, "envelope");
get_table_mut(item, "envelope").map(|item| {
set_tables_dotted(item, ["list", "get"]);
});
set_table_dotted(item, "flag");
get_table_mut(item, "flag").map(|item| {
set_tables_dotted(item, ["add", "set", "remove"]);
});
set_table_dotted(item, "message");
get_table_mut(item, "message").map(|item| {
set_tables_dotted(
item,
["add", "send", "peek", "get", "copy", "move", "delete"],
);
});
set_table_dotted(item, "maildir");
#[cfg(feature = "imap")]
{
set_table_dotted(item, "imap");
get_table_mut(item, "imap").map(|item| {
set_tables_dotted(item, ["passwd", "oauth2"]);
});
}
#[cfg(feature = "notmuch")]
set_table_dotted(item, "notmuch");
set_table_dotted(item, "sendmail");
#[cfg(feature = "smtp")]
{
set_table_dotted(item, "smtp");
get_table_mut(item, "smtp").map(|item| {
set_tables_dotted(item, ["passwd", "oauth2"]);
});
}
#[cfg(feature = "pgp")]
set_table_dotted(item, "pgp");
});
fs::create_dir_all(path.parent().unwrap_or(&path))?;
fs::write(path, toml::to_string(&config)?)?;
fs::write(path, doc.to_string())?;
Ok(config)
}
fn get_table_mut<'a>(item: &'a mut Item, key: &'a str) -> Option<&'a mut Item> {
item.get_mut(key).filter(|item| item.is_table())
}
fn set_table_dotted(item: &mut Item, key: &str) {
get_table_mut(item, key)
.and_then(|item| item.as_table_mut())
.map(|table| table.set_dotted(true));
}
fn set_tables_dotted<'a>(item: &'a mut Item, keys: impl IntoIterator<Item = &'a str>) {
for key in keys {
set_table_dotted(item, key)
}
}
pub(crate) fn prompt_passwd(prompt: &str) -> io::Result<String> {
Password::with_theme(&*THEME)
.with_prompt(prompt)

View file

@ -1,54 +0,0 @@
//! Account module.
//!
//! This module contains the definition of the printable account,
//! which is only used by the "accounts" command to list all available
//! accounts from the config file.
use serde::Serialize;
use std::fmt;
use crate::ui::table::{Cell, Row, Table};
/// Represents the printable account.
#[derive(Debug, Default, PartialEq, Eq, Serialize)]
pub struct Account {
/// Represents the account name.
pub name: String,
/// Represents the backend name of the account.
pub backend: String,
/// Represents the default state of the account.
pub default: bool,
}
impl Account {
pub fn new(name: &str, backend: &str, default: bool) -> Self {
Self {
name: name.into(),
backend: backend.into(),
default,
}
}
}
impl fmt::Display for Account {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}", self.name)
}
}
impl Table for Account {
fn head() -> Row {
Row::new()
.cell(Cell::new("NAME").shrinkable().bold().underline().white())
.cell(Cell::new("BACKEND").bold().underline().white())
.cell(Cell::new("DEFAULT").bold().underline().white())
}
fn row(&self) -> Row {
let default = if self.default { "yes" } else { "" };
Row::new()
.cell(Cell::new(&self.name).shrinkable().green())
.cell(Cell::new(&self.backend).blue())
.cell(Cell::new(default).white())
}
}

View file

@ -1,61 +0,0 @@
//! Account module.
//!
//! This module contains the definition of the printable account,
//! which is only used by the "accounts" command to list all available
//! accounts from the config file.
use anyhow::Result;
use email::backend::BackendConfig;
use serde::Serialize;
use std::{collections::hash_map::Iter, ops::Deref};
use crate::{
printer::{PrintTable, PrintTableOpts, WriteColor},
ui::Table,
};
use super::{Account, DeserializedAccountConfig};
/// Represents the list of printable accounts.
#[derive(Debug, Default, Serialize)]
pub struct Accounts(pub Vec<Account>);
impl Deref for Accounts {
type Target = Vec<Account>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl PrintTable for Accounts {
fn print_table(&self, writer: &mut dyn WriteColor, opts: PrintTableOpts) -> Result<()> {
writeln!(writer)?;
Table::print(writer, self, opts)?;
writeln!(writer)?;
Ok(())
}
}
impl From<Iter<'_, String, DeserializedAccountConfig>> for Accounts {
fn from(map: Iter<'_, String, DeserializedAccountConfig>) -> Self {
let mut accounts: Vec<_> = map
.map(|(name, account)| match &account.backend {
BackendConfig::None => Account::new(name, "none", false),
BackendConfig::Maildir(_) => {
Account::new(name, "maildir", account.default.unwrap_or_default())
}
#[cfg(feature = "imap-backend")]
BackendConfig::Imap(_) => {
Account::new(name, "imap", account.default.unwrap_or_default())
}
#[cfg(feature = "notmuch-backend")]
BackendConfig::Notmuch(_) => {
Account::new(name, "notmuch", account.default.unwrap_or_default())
}
})
.collect();
accounts.sort_by(|a, b| b.name.partial_cmp(&a.name).unwrap());
Self(accounts)
}
}

View file

@ -1,144 +0,0 @@
//! This module provides arguments related to the user account config.
use anyhow::Result;
use clap::{Arg, ArgAction, ArgMatches, Command};
use email::folder::sync::FolderSyncStrategy;
use log::info;
use std::collections::HashSet;
use crate::{folder, ui::table};
const ARG_ACCOUNT: &str = "account";
const ARG_DRY_RUN: &str = "dry-run";
const ARG_RESET: &str = "reset";
const CMD_ACCOUNTS: &str = "accounts";
const CMD_CONFIGURE: &str = "configure";
const CMD_LIST: &str = "list";
const CMD_SYNC: &str = "sync";
type DryRun = bool;
type Reset = bool;
/// Represents the account commands.
#[derive(Debug, PartialEq, Eq)]
pub enum Cmd {
/// Represents the list accounts command.
List(table::args::MaxTableWidth),
/// Represents the sync account command.
Sync(Option<FolderSyncStrategy>, DryRun),
/// Configure the current selected account.
Configure(Reset),
}
/// Represents the account command matcher.
pub fn matches(m: &ArgMatches) -> Result<Option<Cmd>> {
let cmd = if let Some(m) = m.subcommand_matches(CMD_ACCOUNTS) {
if let Some(m) = m.subcommand_matches(CMD_SYNC) {
info!("sync account subcommand matched");
let dry_run = parse_dry_run_arg(m);
let include = folder::args::parse_include_arg(m);
let exclude = folder::args::parse_exclude_arg(m);
let folders_strategy = if let Some(folder) = folder::args::parse_source_arg(m) {
Some(FolderSyncStrategy::Include(HashSet::from_iter([
folder.to_owned()
])))
} else if !include.is_empty() {
Some(FolderSyncStrategy::Include(include.to_owned()))
} else if !exclude.is_empty() {
Some(FolderSyncStrategy::Exclude(exclude))
} else if folder::args::parse_all_arg(m) {
Some(FolderSyncStrategy::All)
} else {
None
};
Some(Cmd::Sync(folders_strategy, dry_run))
} else if let Some(m) = m.subcommand_matches(CMD_LIST) {
info!("list accounts subcommand matched");
let max_table_width = table::args::parse_max_width(m);
Some(Cmd::List(max_table_width))
} else if let Some(m) = m.subcommand_matches(CMD_CONFIGURE) {
info!("configure account subcommand matched");
let reset = parse_reset_flag(m);
Some(Cmd::Configure(reset))
} else {
info!("no account subcommand matched, falling back to subcommand list");
Some(Cmd::List(None))
}
} else {
None
};
Ok(cmd)
}
/// Represents the account subcommand.
pub fn subcmd() -> Command {
Command::new(CMD_ACCOUNTS)
.about("Manage accounts")
.subcommands([
Command::new(CMD_LIST)
.about("List all accounts from the config file")
.arg(table::args::max_width()),
Command::new(CMD_SYNC)
.about("Synchronize the given account locally")
.arg(folder::args::all_arg("Synchronize all folders"))
.arg(folder::args::include_arg(
"Synchronize only the given folders",
))
.arg(folder::args::exclude_arg(
"Synchronize all folders except the given ones",
))
.arg(dry_run()),
Command::new(CMD_CONFIGURE)
.about("Configure the current selected account")
.aliases(["config", "conf", "cfg"])
.arg(reset_flag()),
])
}
/// Represents the user account name argument. This argument allows
/// the user to select a different account than the default one.
pub fn arg() -> Arg {
Arg::new(ARG_ACCOUNT)
.help("Set the account")
.long("account")
.short('a')
.global(true)
.value_name("STRING")
}
/// Represents the user account name argument parser.
pub fn parse_arg(matches: &ArgMatches) -> Option<&str> {
matches.get_one::<String>(ARG_ACCOUNT).map(String::as_str)
}
/// Represents the user account sync dry run flag. This flag allows
/// the user to see the changes of a sync without applying them.
pub fn dry_run() -> Arg {
Arg::new(ARG_DRY_RUN)
.help("Do not apply changes of the synchronization")
.long_help(
"Do not apply changes of the synchronization.
Changes can be visualized with the RUST_LOG=trace environment variable.",
)
.short('d')
.long("dry-run")
.action(ArgAction::SetTrue)
}
/// Represents the user account sync dry run flag parser.
pub fn parse_dry_run_arg(m: &ArgMatches) -> bool {
m.get_flag(ARG_DRY_RUN)
}
pub fn reset_flag() -> Arg {
Arg::new(ARG_RESET)
.help("Reset the configuration")
.short('r')
.long("reset")
.action(ArgAction::SetTrue)
}
pub fn parse_reset_flag(m: &ArgMatches) -> bool {
m.get_flag(ARG_RESET)
}

View file

@ -1,232 +0,0 @@
//! Deserialized account config module.
//!
//! This module contains the raw deserialized representation of an
//! account in the accounts section of the user configuration file.
#[cfg(feature = "pgp")]
use email::account::PgpConfig;
#[cfg(feature = "imap-backend")]
use email::backend::ImapAuthConfig;
#[cfg(feature = "smtp-sender")]
use email::sender::SmtpAuthConfig;
use email::{
account::AccountConfig,
backend::BackendConfig,
email::{EmailHooks, EmailTextPlainFormat},
folder::sync::FolderSyncStrategy,
sender::SenderConfig,
};
use process::Cmd;
use serde::{Deserialize, Serialize};
use std::{collections::HashMap, path::PathBuf};
use crate::config::{prelude::*, DeserializedConfig};
/// Represents all existing kind of account config.
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
#[serde(tag = "backend", rename_all = "kebab-case")]
pub struct DeserializedAccountConfig {
pub email: String,
pub default: Option<bool>,
pub display_name: Option<String>,
pub signature_delim: Option<String>,
pub signature: Option<String>,
pub downloads_dir: Option<PathBuf>,
pub folder_listing_page_size: Option<usize>,
pub folder_aliases: Option<HashMap<String, String>>,
pub email_listing_page_size: Option<usize>,
pub email_listing_datetime_fmt: Option<String>,
pub email_listing_datetime_local_tz: Option<bool>,
pub email_reading_headers: Option<Vec<String>>,
#[serde(
default,
with = "EmailTextPlainFormatDef",
skip_serializing_if = "EmailTextPlainFormat::is_default"
)]
pub email_reading_format: EmailTextPlainFormat,
#[serde(
default,
with = "OptionCmdDef",
skip_serializing_if = "Option::is_none"
)]
pub email_reading_verify_cmd: Option<Cmd>,
#[serde(
default,
with = "OptionCmdDef",
skip_serializing_if = "Option::is_none"
)]
pub email_reading_decrypt_cmd: Option<Cmd>,
pub email_writing_headers: Option<Vec<String>>,
#[serde(
default,
with = "OptionCmdDef",
skip_serializing_if = "Option::is_none"
)]
pub email_writing_sign_cmd: Option<Cmd>,
#[serde(
default,
with = "OptionCmdDef",
skip_serializing_if = "Option::is_none"
)]
pub email_writing_encrypt_cmd: Option<Cmd>,
pub email_sending_save_copy: Option<bool>,
#[serde(
default,
with = "EmailHooksDef",
skip_serializing_if = "EmailHooks::is_empty"
)]
pub email_hooks: EmailHooks,
pub sync: Option<bool>,
pub sync_dir: Option<PathBuf>,
#[serde(
default,
with = "FolderSyncStrategyDef",
skip_serializing_if = "FolderSyncStrategy::is_default"
)]
pub sync_folders_strategy: FolderSyncStrategy,
#[serde(flatten, with = "BackendConfigDef")]
pub backend: BackendConfig,
#[serde(flatten, with = "SenderConfigDef")]
pub sender: SenderConfig,
#[cfg(feature = "pgp")]
#[serde(default, with = "PgpConfigDef")]
pub pgp: PgpConfig,
}
impl DeserializedAccountConfig {
pub fn to_account_config(&self, name: String, config: &DeserializedConfig) -> AccountConfig {
let mut folder_aliases = config
.folder_aliases
.as_ref()
.map(ToOwned::to_owned)
.unwrap_or_default();
folder_aliases.extend(
self.folder_aliases
.as_ref()
.map(ToOwned::to_owned)
.unwrap_or_default(),
);
AccountConfig {
name: name.clone(),
email: self.email.to_owned(),
display_name: self
.display_name
.as_ref()
.map(ToOwned::to_owned)
.or_else(|| config.display_name.as_ref().map(ToOwned::to_owned)),
signature_delim: self
.signature_delim
.as_ref()
.map(ToOwned::to_owned)
.or_else(|| config.signature_delim.as_ref().map(ToOwned::to_owned)),
signature: self
.signature
.as_ref()
.map(ToOwned::to_owned)
.or_else(|| config.signature.as_ref().map(ToOwned::to_owned)),
downloads_dir: self
.downloads_dir
.as_ref()
.map(ToOwned::to_owned)
.or_else(|| config.downloads_dir.as_ref().map(ToOwned::to_owned)),
folder_listing_page_size: self
.folder_listing_page_size
.or_else(|| config.folder_listing_page_size),
folder_aliases,
email_listing_page_size: self
.email_listing_page_size
.or_else(|| config.email_listing_page_size),
email_listing_datetime_fmt: self
.email_listing_datetime_fmt
.as_ref()
.map(ToOwned::to_owned)
.or_else(|| {
config
.email_listing_datetime_fmt
.as_ref()
.map(ToOwned::to_owned)
}),
email_listing_datetime_local_tz: self
.email_listing_datetime_local_tz
.or_else(|| config.email_listing_datetime_local_tz),
email_reading_headers: self
.email_reading_headers
.as_ref()
.map(ToOwned::to_owned)
.or_else(|| config.email_reading_headers.as_ref().map(ToOwned::to_owned)),
email_reading_format: self.email_reading_format.clone(),
email_writing_headers: self
.email_writing_headers
.as_ref()
.map(ToOwned::to_owned)
.or_else(|| config.email_writing_headers.as_ref().map(ToOwned::to_owned)),
email_sending_save_copy: self.email_sending_save_copy.unwrap_or(true),
email_hooks: EmailHooks {
pre_send: self.email_hooks.pre_send.clone(),
},
sync: self.sync.unwrap_or_default(),
sync_dir: self.sync_dir.clone(),
sync_folders_strategy: self.sync_folders_strategy.clone(),
backend: {
let mut backend = self.backend.clone();
#[cfg(feature = "imap-backend")]
if let BackendConfig::Imap(config) = &mut backend {
match &mut config.auth {
ImapAuthConfig::Passwd(secret) => {
secret.set_keyring_entry_if_undefined(format!("{name}-imap-passwd"));
}
ImapAuthConfig::OAuth2(config) => {
config.client_secret.set_keyring_entry_if_undefined(format!(
"{name}-imap-oauth2-client-secret"
));
config.access_token.set_keyring_entry_if_undefined(format!(
"{name}-imap-oauth2-access-token"
));
config.refresh_token.set_keyring_entry_if_undefined(format!(
"{name}-imap-oauth2-refresh-token"
));
}
};
}
backend
},
sender: {
let mut sender = self.sender.clone();
#[cfg(feature = "smtp-sender")]
if let SenderConfig::Smtp(config) = &mut sender {
match &mut config.auth {
SmtpAuthConfig::Passwd(secret) => {
secret.set_keyring_entry_if_undefined(format!("{name}-smtp-passwd"));
}
SmtpAuthConfig::OAuth2(config) => {
config.client_secret.set_keyring_entry_if_undefined(format!(
"{name}-smtp-oauth2-client-secret"
));
config.access_token.set_keyring_entry_if_undefined(format!(
"{name}-smtp-oauth2-access-token"
));
config.refresh_token.set_keyring_entry_if_undefined(format!(
"{name}-smtp-oauth2-refresh-token"
));
}
};
}
sender
},
#[cfg(feature = "pgp")]
pgp: self.pgp.clone(),
}
}
}

View file

@ -1,396 +0,0 @@
//! Account handlers module.
//!
//! This module gathers all account actions triggered by the CLI.
use anyhow::Result;
#[cfg(feature = "imap-backend")]
use email::backend::ImapAuthConfig;
#[cfg(feature = "smtp-sender")]
use email::sender::SmtpAuthConfig;
use email::{
account::{
sync::{AccountSyncBuilder, AccountSyncProgressEvent},
AccountConfig,
},
backend::BackendConfig,
sender::SenderConfig,
};
use indicatif::{MultiProgress, ProgressBar, ProgressFinish, ProgressStyle};
use log::{info, trace, warn};
use once_cell::sync::Lazy;
use std::{collections::HashMap, sync::Mutex};
use crate::{
config::{
wizard::{prompt_passwd, prompt_secret},
DeserializedConfig,
},
printer::{PrintTableOpts, Printer},
Accounts,
};
const MAIN_PROGRESS_STYLE: Lazy<ProgressStyle> = Lazy::new(|| {
ProgressStyle::with_template(" {spinner:.dim} {msg:.dim}\n {wide_bar:.cyan/blue} \n").unwrap()
});
const SUB_PROGRESS_STYLE: Lazy<ProgressStyle> = Lazy::new(|| {
ProgressStyle::with_template(
" {prefix:.bold} — {wide_msg:.dim} \n {wide_bar:.black/black} {percent}% ",
)
.unwrap()
});
const SUB_PROGRESS_DONE_STYLE: Lazy<ProgressStyle> = Lazy::new(|| {
ProgressStyle::with_template(" {prefix:.bold} \n {wide_bar:.green} {percent}% ").unwrap()
});
/// Configure the current selected account
pub async fn configure(config: &AccountConfig, reset: bool) -> Result<()> {
info!("entering the configure account handler");
if reset {
#[cfg(feature = "imap-backend")]
if let BackendConfig::Imap(imap_config) = &config.backend {
let reset = match &imap_config.auth {
ImapAuthConfig::Passwd(passwd) => passwd.reset(),
ImapAuthConfig::OAuth2(oauth2) => oauth2.reset(),
};
if let Err(err) = reset {
warn!("error while resetting imap secrets, skipping it");
warn!("{err}");
}
}
#[cfg(feature = "smtp-sender")]
if let SenderConfig::Smtp(smtp_config) = &config.sender {
let reset = match &smtp_config.auth {
SmtpAuthConfig::Passwd(passwd) => passwd.reset(),
SmtpAuthConfig::OAuth2(oauth2) => oauth2.reset(),
};
if let Err(err) = reset {
warn!("error while resetting smtp secrets, skipping it");
warn!("{err}");
}
}
#[cfg(feature = "pgp")]
config.pgp.reset().await?;
}
#[cfg(feature = "imap-backend")]
if let BackendConfig::Imap(imap_config) = &config.backend {
match &imap_config.auth {
ImapAuthConfig::Passwd(passwd) => {
passwd.configure(|| prompt_passwd("IMAP password")).await
}
ImapAuthConfig::OAuth2(oauth2) => {
oauth2
.configure(|| prompt_secret("IMAP OAuth 2.0 client secret"))
.await
}
}?;
}
#[cfg(feature = "smtp-sender")]
if let SenderConfig::Smtp(smtp_config) = &config.sender {
match &smtp_config.auth {
SmtpAuthConfig::Passwd(passwd) => {
passwd.configure(|| prompt_passwd("SMTP password")).await
}
SmtpAuthConfig::OAuth2(oauth2) => {
oauth2
.configure(|| prompt_secret("SMTP OAuth 2.0 client secret"))
.await
}
}?;
}
#[cfg(feature = "pgp")]
config
.pgp
.configure(&config.email, || prompt_passwd("PGP secret key password"))
.await?;
println!(
"Account successfully {}configured!",
if reset { "re" } else { "" }
);
Ok(())
}
/// Lists all accounts.
pub fn list<'a, P: Printer>(
max_width: Option<usize>,
config: &AccountConfig,
deserialized_config: &DeserializedConfig,
printer: &mut P,
) -> Result<()> {
info!("entering the list accounts handler");
let accounts: Accounts = deserialized_config.accounts.iter().into();
trace!("accounts: {:?}", accounts);
printer.print_table(
Box::new(accounts),
PrintTableOpts {
format: &config.email_reading_format,
max_width,
},
)?;
info!("<< account list handler");
Ok(())
}
/// Synchronizes the account defined using argument `-a|--account`. If
/// no account given, synchronizes the default one.
pub async fn sync<P: Printer>(
printer: &mut P,
sync_builder: AccountSyncBuilder,
dry_run: bool,
) -> Result<()> {
info!("entering the sync accounts handler");
trace!("dry run: {dry_run}");
if dry_run {
let report = sync_builder.sync().await?;
let mut hunks_count = report.folders_patch.len();
if !report.folders_patch.is_empty() {
printer.print_log("Folders patch:")?;
for (hunk, _) in report.folders_patch {
printer.print_log(format!(" - {hunk}"))?;
}
printer.print_log("")?;
}
if !report.emails_patch.is_empty() {
printer.print_log("Envelopes patch:")?;
for (hunk, _) in report.emails_patch {
hunks_count += 1;
printer.print_log(format!(" - {hunk}"))?;
}
printer.print_log("")?;
}
printer.print(format!(
"Estimated patch length for account to be synchronized: {hunks_count}",
))?;
} else if printer.is_json() {
sync_builder.sync().await?;
printer.print("Account successfully synchronized!")?;
} else {
let multi = MultiProgress::new();
let sub_progresses = Mutex::new(HashMap::new());
let main_progress = multi.add(
ProgressBar::new(100)
.with_style(MAIN_PROGRESS_STYLE.clone())
.with_message("Synchronizing folders…"),
);
// Force the progress bar to show
main_progress.set_position(0);
let report = sync_builder
.with_on_progress(move |evt| {
use AccountSyncProgressEvent::*;
Ok(match evt {
ApplyFolderPatches(..) => {
main_progress.inc(3);
}
ApplyEnvelopePatches(patches) => {
let mut envelopes_progresses = sub_progresses.lock().unwrap();
let patches_len = patches.values().fold(0, |sum, patch| sum + patch.len());
main_progress.set_length((110 * patches_len / 100) as u64);
main_progress.set_position((5 * patches_len / 100) as u64);
main_progress.set_message("Synchronizing envelopes…");
for (folder, patch) in patches {
let progress = ProgressBar::new(patch.len() as u64)
.with_style(SUB_PROGRESS_STYLE.clone())
.with_prefix(folder.clone())
.with_finish(ProgressFinish::AndClear);
let progress = multi.add(progress);
envelopes_progresses.insert(folder, progress.clone());
}
}
ApplyEnvelopeHunk(hunk) => {
main_progress.inc(1);
let mut progresses = sub_progresses.lock().unwrap();
if let Some(progress) = progresses.get_mut(hunk.folder()) {
progress.inc(1);
if progress.position() == (progress.length().unwrap() - 1) {
progress.set_style(SUB_PROGRESS_DONE_STYLE.clone())
} else {
progress.set_message(format!("{hunk}"));
}
}
}
ApplyEnvelopeCachePatch(_patch) => {
main_progress.set_length(100);
main_progress.set_position(95);
main_progress.set_message("Saving cache database…");
}
ExpungeFolders(folders) => {
let mut progresses = sub_progresses.lock().unwrap();
for progress in progresses.values() {
progress.finish_and_clear()
}
progresses.clear();
main_progress.set_position(100);
main_progress.set_message(format!("Expunging {} folders…", folders.len()));
}
_ => (),
})
})
.sync()
.await?;
let folders_patch_err = report
.folders_patch
.iter()
.filter_map(|(hunk, err)| err.as_ref().map(|err| (hunk, err)))
.collect::<Vec<_>>();
if !folders_patch_err.is_empty() {
printer.print_log("")?;
printer.print_log("Errors occurred while applying the folders patch:")?;
folders_patch_err
.iter()
.try_for_each(|(hunk, err)| printer.print_log(format!(" - {hunk}: {err}")))?;
}
if let Some(err) = report.folders_cache_patch.1 {
printer.print_log("")?;
printer.print_log(format!(
"Error occurred while applying the folder cache patch: {err}"
))?;
}
let envelopes_patch_err = report
.emails_patch
.iter()
.filter_map(|(hunk, err)| err.as_ref().map(|err| (hunk, err)))
.collect::<Vec<_>>();
if !envelopes_patch_err.is_empty() {
printer.print_log("")?;
printer.print_log("Errors occurred while applying the envelopes patch:")?;
for (hunk, err) in folders_patch_err {
printer.print_log(format!(" - {hunk}: {err}"))?;
}
}
if let Some(err) = report.emails_cache_patch.1 {
printer.print_log("")?;
printer.print_log(format!(
"Error occurred while applying the envelopes cache patch: {err}"
))?;
}
printer.print("Account successfully synchronized!")?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use email::{account::AccountConfig, backend::ImapConfig};
use std::{collections::HashMap, fmt::Debug, io};
use termcolor::ColorSpec;
use crate::{
account::DeserializedAccountConfig,
printer::{Print, PrintTable, WriteColor},
};
use super::*;
#[test]
fn it_should_match_cmds_accounts() {
#[derive(Debug, Default, Clone)]
struct StringWriter {
content: String,
}
impl io::Write for StringWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.content
.push_str(&String::from_utf8(buf.to_vec()).unwrap());
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
self.content = String::default();
Ok(())
}
}
impl termcolor::WriteColor for StringWriter {
fn supports_color(&self) -> bool {
false
}
fn set_color(&mut self, _spec: &ColorSpec) -> io::Result<()> {
io::Result::Ok(())
}
fn reset(&mut self) -> io::Result<()> {
io::Result::Ok(())
}
}
impl WriteColor for StringWriter {}
#[derive(Debug, Default)]
struct PrinterServiceTest {
pub writer: StringWriter,
}
impl Printer for PrinterServiceTest {
fn print_table<T: Debug + PrintTable + erased_serde::Serialize + ?Sized>(
&mut self,
data: Box<T>,
opts: PrintTableOpts,
) -> Result<()> {
data.print_table(&mut self.writer, opts)?;
Ok(())
}
fn print_log<T: Debug + Print>(&mut self, _data: T) -> Result<()> {
unimplemented!()
}
fn print<T: Debug + Print + serde::Serialize>(&mut self, _data: T) -> Result<()> {
unimplemented!()
}
fn is_json(&self) -> bool {
unimplemented!()
}
}
let mut printer = PrinterServiceTest::default();
let config = AccountConfig::default();
let deserialized_config = DeserializedConfig {
accounts: HashMap::from_iter([(
"account-1".into(),
DeserializedAccountConfig {
default: Some(true),
backend: BackendConfig::Imap(ImapConfig::default()),
..DeserializedAccountConfig::default()
},
)]),
..DeserializedConfig::default()
};
assert!(list(None, &config, &deserialized_config, &mut printer).is_ok());
assert_eq!(
concat![
"\n",
"NAME │BACKEND │DEFAULT \n",
"account-1 │imap │yes \n",
"\n"
],
printer.writer.content
);
}
}

View file

@ -1,10 +0,0 @@
pub mod account;
pub mod accounts;
pub mod args;
pub mod config;
pub mod handlers;
pub(crate) mod wizard;
pub use account::*;
pub use accounts::*;
pub use config::*;

View file

@ -1,39 +0,0 @@
use anyhow::{anyhow, Result};
use dialoguer::Input;
use email_address::EmailAddress;
use crate::{backend, config::wizard::THEME, sender};
use super::DeserializedAccountConfig;
pub(crate) async fn configure() -> Result<Option<(String, DeserializedAccountConfig)>> {
let mut config = DeserializedAccountConfig::default();
let account_name = Input::with_theme(&*THEME)
.with_prompt("Account name")
.default(String::from("Personal"))
.interact()?;
config.email = Input::with_theme(&*THEME)
.with_prompt("Email address")
.validate_with(|email: &String| {
if EmailAddress::is_valid(email) {
Ok(())
} else {
Err(anyhow!("Invalid email address: {email}"))
}
})
.interact()?;
config.display_name = Some(
Input::with_theme(&*THEME)
.with_prompt("Full display name")
.interact()?,
);
config.backend = backend::wizard::configure(&account_name, &config.email).await?;
config.sender = sender::wizard::configure(&account_name, &config.email).await?;
Ok(Some((account_name, config)))
}

View file

@ -1,3 +0,0 @@
pub mod args;
pub mod handlers;
pub(crate) mod wizard;

View file

@ -1,6 +0,0 @@
#[cfg(feature = "imap-backend")]
pub mod imap;
pub mod maildir;
#[cfg(feature = "notmuch-backend")]
pub mod notmuch;
pub(crate) mod wizard;

View file

@ -1,44 +0,0 @@
use anyhow::Result;
use dialoguer::Select;
use email::backend::BackendConfig;
use crate::config::wizard::THEME;
#[cfg(feature = "imap-backend")]
use super::imap;
use super::maildir;
#[cfg(feature = "notmuch-backend")]
use super::notmuch;
#[cfg(feature = "imap-backend")]
const IMAP: &str = "IMAP";
const MAILDIR: &str = "Maildir";
#[cfg(feature = "notmuch-backend")]
const NOTMUCH: &str = "Notmuch";
const NONE: &str = "None";
const BACKENDS: &[&str] = &[
#[cfg(feature = "imap-backend")]
IMAP,
MAILDIR,
#[cfg(feature = "notmuch-backend")]
NOTMUCH,
NONE,
];
pub(crate) async fn configure(account_name: &str, email: &str) -> Result<BackendConfig> {
let backend = Select::with_theme(&*THEME)
.with_prompt("Email backend")
.items(BACKENDS)
.default(0)
.interact_opt()?;
match backend {
#[cfg(feature = "imap-backend")]
Some(idx) if BACKENDS[idx] == IMAP => imap::wizard::configure(account_name, email).await,
Some(idx) if BACKENDS[idx] == MAILDIR => maildir::wizard::configure(),
#[cfg(feature = "notmuch-backend")]
Some(idx) if BACKENDS[idx] == NOTMUCH => notmuch::wizard::configure(),
_ => Ok(BackendConfig::None),
}
}

View file

@ -1,438 +0,0 @@
//! Email CLI module.
//!
//! This module contains the command matcher, the subcommands and the
//! arguments related to the email domain.
use anyhow::Result;
use clap::{Arg, ArgAction, ArgMatches, Command};
use crate::{flag, folder, tpl, ui::table};
const ARG_CRITERIA: &str = "criterion";
const ARG_HEADERS: &str = "headers";
const ARG_ID: &str = "id";
const ARG_IDS: &str = "ids";
const ARG_MIME_TYPE: &str = "mime-type";
const ARG_PAGE: &str = "page";
const ARG_PAGE_SIZE: &str = "page-size";
const ARG_QUERY: &str = "query";
const ARG_RAW: &str = "raw";
const ARG_REPLY_ALL: &str = "reply-all";
const CMD_ATTACHMENTS: &str = "attachments";
const CMD_COPY: &str = "copy";
const CMD_DELETE: &str = "delete";
const CMD_FORWARD: &str = "forward";
const CMD_LIST: &str = "list";
const CMD_MOVE: &str = "move";
const CMD_READ: &str = "read";
const CMD_REPLY: &str = "reply";
const CMD_SAVE: &str = "save";
const CMD_SEARCH: &str = "search";
const CMD_SEND: &str = "send";
const CMD_SORT: &str = "sort";
const CMD_WRITE: &str = "write";
pub type All = bool;
pub type Criteria = String;
pub type Folder<'a> = &'a str;
pub type Headers<'a> = Vec<&'a str>;
pub type Id<'a> = &'a str;
pub type Ids<'a> = Vec<&'a str>;
pub type Page = usize;
pub type PageSize = usize;
pub type Query = String;
pub type Raw = bool;
pub type RawEmail = String;
pub type TextMime<'a> = &'a str;
/// Represents the email commands.
#[derive(Debug, PartialEq, Eq)]
pub enum Cmd<'a> {
Attachments(Ids<'a>),
Copy(Ids<'a>, Folder<'a>),
Delete(Ids<'a>),
Flag(Option<flag::args::Cmd<'a>>),
Forward(Id<'a>, tpl::args::Headers<'a>, tpl::args::Body<'a>),
List(table::args::MaxTableWidth, Option<PageSize>, Page),
Move(Ids<'a>, Folder<'a>),
Read(Ids<'a>, TextMime<'a>, Raw, Headers<'a>),
Reply(Id<'a>, All, tpl::args::Headers<'a>, tpl::args::Body<'a>),
Save(RawEmail),
Search(Query, table::args::MaxTableWidth, Option<PageSize>, Page),
Send(RawEmail),
Sort(
Criteria,
Query,
table::args::MaxTableWidth,
Option<PageSize>,
Page,
),
Tpl(Option<tpl::args::Cmd<'a>>),
Write(tpl::args::Headers<'a>, tpl::args::Body<'a>),
}
/// Email command matcher.
pub fn matches<'a>(m: &'a ArgMatches) -> Result<Option<Cmd<'a>>> {
let cmd = if let Some(m) = m.subcommand_matches(CMD_ATTACHMENTS) {
let ids = parse_ids_arg(m);
Cmd::Attachments(ids)
} else if let Some(m) = m.subcommand_matches(CMD_COPY) {
let ids = parse_ids_arg(m);
let folder = folder::args::parse_target_arg(m);
Cmd::Copy(ids, folder)
} else if let Some(m) = m.subcommand_matches(CMD_DELETE) {
let ids = parse_ids_arg(m);
Cmd::Delete(ids)
} else if let Some(m) = m.subcommand_matches(flag::args::CMD_FLAG) {
Cmd::Flag(flag::args::matches(m)?)
} else if let Some(m) = m.subcommand_matches(CMD_FORWARD) {
let id = parse_id_arg(m);
let headers = tpl::args::parse_headers_arg(m);
let body = tpl::args::parse_body_arg(m);
Cmd::Forward(id, headers, body)
} else if let Some(m) = m.subcommand_matches(CMD_LIST) {
let max_table_width = table::args::parse_max_width(m);
let page_size = parse_page_size_arg(m);
let page = parse_page_arg(m);
Cmd::List(max_table_width, page_size, page)
} else if let Some(m) = m.subcommand_matches(CMD_MOVE) {
let ids = parse_ids_arg(m);
let folder = folder::args::parse_target_arg(m);
Cmd::Move(ids, folder)
} else if let Some(m) = m.subcommand_matches(CMD_READ) {
let ids = parse_ids_arg(m);
let mime = parse_mime_type_arg(m);
let raw = parse_raw_flag(m);
let headers = parse_headers_arg(m);
Cmd::Read(ids, mime, raw, headers)
} else if let Some(m) = m.subcommand_matches(CMD_REPLY) {
let id = parse_id_arg(m);
let all = parse_reply_all_flag(m);
let headers = tpl::args::parse_headers_arg(m);
let body = tpl::args::parse_body_arg(m);
Cmd::Reply(id, all, headers, body)
} else if let Some(m) = m.subcommand_matches(CMD_SAVE) {
let email = parse_raw_arg(m);
Cmd::Save(email)
} else if let Some(m) = m.subcommand_matches(CMD_SEARCH) {
let max_table_width = table::args::parse_max_width(m);
let page_size = parse_page_size_arg(m);
let page = parse_page_arg(m);
let query = parse_query_arg(m);
Cmd::Search(query, max_table_width, page_size, page)
} else if let Some(m) = m.subcommand_matches(CMD_SORT) {
let max_table_width = table::args::parse_max_width(m);
let page_size = parse_page_size_arg(m);
let page = parse_page_arg(m);
let criteria = parse_criteria_arg(m);
let query = parse_query_arg(m);
Cmd::Sort(criteria, query, max_table_width, page_size, page)
} else if let Some(m) = m.subcommand_matches(CMD_SEND) {
let email = parse_raw_arg(m);
Cmd::Send(email)
} else if let Some(m) = m.subcommand_matches(tpl::args::CMD_TPL) {
Cmd::Tpl(tpl::args::matches(m)?)
} else if let Some(m) = m.subcommand_matches(CMD_WRITE) {
let headers = tpl::args::parse_headers_arg(m);
let body = tpl::args::parse_body_arg(m);
Cmd::Write(headers, body)
} else {
Cmd::List(None, None, 0)
};
Ok(Some(cmd))
}
/// Represents the email subcommands.
pub fn subcmds() -> Vec<Command> {
vec![
flag::args::subcmds(),
tpl::args::subcmds(),
vec![
Command::new(CMD_ATTACHMENTS)
.about("Downloads all emails attachments")
.arg(ids_arg()),
Command::new(CMD_LIST)
.alias("lst")
.about("List envelopes")
.arg(page_size_arg())
.arg(page_arg())
.arg(table::args::max_width()),
Command::new(CMD_SEARCH)
.aliases(["query", "q"])
.about("Filter envelopes matching the given query")
.arg(page_size_arg())
.arg(page_arg())
.arg(table::args::max_width())
.arg(query_arg()),
Command::new(CMD_SORT)
.about("Sort envelopes by the given criteria and matching the given query")
.arg(page_size_arg())
.arg(page_arg())
.arg(table::args::max_width())
.arg(criteria_arg())
.arg(query_arg()),
Command::new(CMD_WRITE)
.about("Write a new email")
.aliases(["new", "n"])
.args(tpl::args::args()),
Command::new(CMD_SEND)
.about("Send a raw email")
.arg(raw_arg()),
Command::new(CMD_SAVE)
.about("Save a raw email")
.arg(raw_arg()),
Command::new(CMD_READ)
.about("Read text bodies of emails")
.arg(mime_type_arg())
.arg(raw_flag())
.arg(headers_arg())
.arg(ids_arg()),
Command::new(CMD_REPLY)
.about("Answer to an email")
.arg(reply_all_flag())
.args(tpl::args::args())
.arg(id_arg()),
Command::new(CMD_FORWARD)
.aliases(["fwd", "f"])
.about("Forward an email")
.args(tpl::args::args())
.arg(id_arg()),
Command::new(CMD_COPY)
.alias("cp")
.about("Copy emails to the given folder")
.arg(folder::args::target_arg())
.arg(ids_arg()),
Command::new(CMD_MOVE)
.alias("mv")
.about("Move emails to the given folder")
.arg(folder::args::target_arg())
.arg(ids_arg()),
Command::new(CMD_DELETE)
.aliases(["remove", "rm"])
.about("Delete emails")
.arg(ids_arg()),
],
]
.concat()
}
/// Represents the email id argument.
pub fn id_arg() -> Arg {
Arg::new(ARG_ID)
.help("Specifies the target email")
.value_name("ID")
.required(true)
}
/// Represents the email id argument parser.
pub fn parse_id_arg(matches: &ArgMatches) -> &str {
matches.get_one::<String>(ARG_ID).unwrap()
}
/// Represents the email ids argument.
pub fn ids_arg() -> Arg {
Arg::new(ARG_IDS)
.help("Email ids")
.value_name("IDS")
.num_args(1..)
.required(true)
}
/// Represents the email ids argument parser.
pub fn parse_ids_arg(matches: &ArgMatches) -> Vec<&str> {
matches
.get_many::<String>(ARG_IDS)
.unwrap()
.map(String::as_str)
.collect()
}
/// Represents the email sort criteria argument.
pub fn criteria_arg<'a>() -> Arg {
Arg::new(ARG_CRITERIA)
.help("Email sorting preferences")
.long("criterion")
.short('c')
.value_name("CRITERION:ORDER")
.action(ArgAction::Append)
.value_parser([
"arrival",
"arrival:asc",
"arrival:desc",
"cc",
"cc:asc",
"cc:desc",
"date",
"date:asc",
"date:desc",
"from",
"from:asc",
"from:desc",
"size",
"size:asc",
"size:desc",
"subject",
"subject:asc",
"subject:desc",
"to",
"to:asc",
"to:desc",
])
}
/// Represents the email sort criteria argument parser.
pub fn parse_criteria_arg(matches: &ArgMatches) -> String {
matches
.get_many::<String>(ARG_CRITERIA)
.unwrap_or_default()
.map(ToOwned::to_owned)
.collect::<Vec<_>>()
.join(" ")
}
/// Represents the email reply all argument.
pub fn reply_all_flag() -> Arg {
Arg::new(ARG_REPLY_ALL)
.help("Include all recipients")
.long("all")
.short('A')
.action(ArgAction::SetTrue)
}
/// Represents the email reply all argument parser.
pub fn parse_reply_all_flag(matches: &ArgMatches) -> bool {
matches.get_flag(ARG_REPLY_ALL)
}
/// Represents the page size argument.
fn page_size_arg() -> Arg {
Arg::new(ARG_PAGE_SIZE)
.help("Page size")
.long("page-size")
.short('s')
.value_name("INT")
}
/// Represents the page size argument parser.
fn parse_page_size_arg(matches: &ArgMatches) -> Option<usize> {
matches
.get_one::<String>(ARG_PAGE_SIZE)
.and_then(|s| s.parse().ok())
}
/// Represents the page argument.
fn page_arg() -> Arg {
Arg::new(ARG_PAGE)
.help("Page number")
.short('p')
.long("page")
.value_name("INT")
.default_value("1")
}
/// Represents the page argument parser.
fn parse_page_arg(matches: &ArgMatches) -> usize {
matches
.get_one::<String>(ARG_PAGE)
.unwrap()
.parse()
.ok()
.map(|page| 1.max(page) - 1)
.unwrap_or_default()
}
/// Represents the email headers argument.
pub fn headers_arg() -> Arg {
Arg::new(ARG_HEADERS)
.help("Shows additional headers with the email")
.long("header")
.short('H')
.value_name("STRING")
.action(ArgAction::Append)
}
/// Represents the email headers argument parser.
pub fn parse_headers_arg(m: &ArgMatches) -> Vec<&str> {
m.get_many::<String>(ARG_HEADERS)
.unwrap_or_default()
.map(String::as_str)
.collect::<Vec<_>>()
}
/// Represents the raw flag.
pub fn raw_flag() -> Arg {
Arg::new(ARG_RAW)
.help("Returns raw version of email")
.long("raw")
.short('r')
.action(ArgAction::SetTrue)
}
/// Represents the raw flag parser.
pub fn parse_raw_flag(m: &ArgMatches) -> bool {
m.get_flag(ARG_RAW)
}
/// Represents the email raw argument.
pub fn raw_arg() -> Arg {
Arg::new(ARG_RAW).raw(true)
}
/// Represents the email raw argument parser.
pub fn parse_raw_arg(m: &ArgMatches) -> String {
m.get_one::<String>(ARG_RAW).cloned().unwrap_or_default()
}
/// Represents the email MIME type argument.
pub fn mime_type_arg() -> Arg {
Arg::new(ARG_MIME_TYPE)
.help("MIME type to use")
.short('t')
.long("mime-type")
.value_name("MIME")
.value_parser(["plain", "html"])
.default_value("plain")
}
/// Represents the email MIME type argument parser.
pub fn parse_mime_type_arg(matches: &ArgMatches) -> &str {
matches.get_one::<String>(ARG_MIME_TYPE).unwrap()
}
/// Represents the email query argument.
pub fn query_arg() -> Arg {
Arg::new(ARG_QUERY)
.long_help("The query system depends on the backend, see the wiki for more details")
.value_name("QUERY")
.num_args(1..)
.required(true)
}
/// Represents the email query argument parser.
pub fn parse_query_arg(matches: &ArgMatches) -> String {
matches
.get_many::<String>(ARG_QUERY)
.unwrap_or_default()
.fold((false, vec![]), |(escape, mut cmds), cmd| {
match (cmd.as_str(), escape) {
// Next command is an arg and needs to be escaped
("subject", _) | ("body", _) | ("text", _) => {
cmds.push(cmd.to_string());
(true, cmds)
}
// Escaped arg commands
(_, true) => {
cmds.push(format!("\"{}\"", cmd));
(false, cmds)
}
// Regular commands
(_, false) => {
cmds.push(cmd.to_string());
(false, cmds)
}
}
})
.1
.join(" ")
}

View file

@ -1,421 +0,0 @@
use anyhow::{anyhow, Context, Result};
use atty::Stream;
use email::{
account::AccountConfig,
backend::Backend,
email::{template::FilterParts, Flag, Flags, Message, MessageBuilder},
sender::Sender,
};
use log::{debug, trace};
use std::{
fs,
io::{self, BufRead},
};
use url::Url;
use uuid::Uuid;
use crate::{
printer::{PrintTableOpts, Printer},
ui::editor,
Envelopes, IdMapper,
};
pub async fn attachments<P: Printer>(
config: &AccountConfig,
printer: &mut P,
id_mapper: &IdMapper,
backend: &mut dyn Backend,
folder: &str,
ids: Vec<&str>,
) -> Result<()> {
let ids = id_mapper.get_ids(ids)?;
let ids = ids.iter().map(String::as_str).collect::<Vec<_>>();
let emails = backend.get_emails(&folder, ids.clone()).await?;
let mut index = 0;
let mut emails_count = 0;
let mut attachments_count = 0;
for email in emails.to_vec() {
let id = ids.get(index).unwrap();
let attachments = email.attachments()?;
index = index + 1;
if attachments.is_empty() {
printer.print_log(format!("No attachment found for email #{}", id))?;
continue;
} else {
emails_count = emails_count + 1;
}
printer.print_log(format!(
"{} attachment(s) found for email #{}…",
attachments.len(),
id
))?;
for attachment in attachments {
let filename = attachment
.filename
.unwrap_or_else(|| Uuid::new_v4().to_string());
let filepath = config.download_fpath(&filename)?;
printer.print_log(format!("Downloading {:?}", filepath))?;
fs::write(&filepath, &attachment.body).context("cannot download attachment")?;
attachments_count = attachments_count + 1;
}
}
match attachments_count {
0 => printer.print("No attachment found!"),
1 => printer.print("Downloaded 1 attachment!"),
n => printer.print(format!(
"Downloaded {} attachment(s) from {} email(s)!",
n, emails_count,
)),
}
}
pub async fn copy<P: Printer>(
printer: &mut P,
id_mapper: &IdMapper,
backend: &mut dyn Backend,
from_folder: &str,
to_folder: &str,
ids: Vec<&str>,
) -> Result<()> {
let ids = id_mapper.get_ids(ids)?;
let ids = ids.iter().map(String::as_str).collect::<Vec<_>>();
backend.copy_emails(&from_folder, &to_folder, ids).await?;
printer.print("Email(s) successfully copied!")
}
pub async fn delete<P: Printer>(
printer: &mut P,
id_mapper: &IdMapper,
backend: &mut dyn Backend,
folder: &str,
ids: Vec<&str>,
) -> Result<()> {
let ids = id_mapper.get_ids(ids)?;
let ids = ids.iter().map(String::as_str).collect::<Vec<_>>();
backend.delete_emails(&folder, ids).await?;
printer.print("Email(s) successfully deleted!")
}
pub async fn forward<P: Printer>(
config: &AccountConfig,
printer: &mut P,
id_mapper: &IdMapper,
backend: &mut dyn Backend,
sender: &mut dyn Sender,
folder: &str,
id: &str,
headers: Option<Vec<(&str, &str)>>,
body: Option<&str>,
) -> Result<()> {
let ids = id_mapper.get_ids([id])?;
let ids = ids.iter().map(String::as_str).collect::<Vec<_>>();
let tpl = backend
.get_emails(&folder, ids)
.await?
.first()
.ok_or_else(|| anyhow!("cannot find email {}", id))?
.to_forward_tpl_builder(config)
.with_some_headers(headers)
.with_some_body(body)
.build()
.await?;
trace!("initial template: {tpl}");
editor::edit_tpl_with_editor(config, printer, backend, sender, tpl).await?;
Ok(())
}
pub async fn list<P: Printer>(
config: &AccountConfig,
printer: &mut P,
id_mapper: &IdMapper,
backend: &mut dyn Backend,
folder: &str,
max_width: Option<usize>,
page_size: Option<usize>,
page: usize,
) -> Result<()> {
let page_size = page_size.unwrap_or(config.email_listing_page_size());
debug!("page size: {}", page_size);
let envelopes = Envelopes::from_backend(
config,
id_mapper,
backend.list_envelopes(&folder, page_size, page).await?,
)?;
trace!("envelopes: {:?}", envelopes);
printer.print_table(
Box::new(envelopes),
PrintTableOpts {
format: &config.email_reading_format,
max_width,
},
)
}
/// Parses and edits a message from a [mailto] URL string.
///
/// [mailto]: https://en.wikipedia.org/wiki/Mailto
pub async fn mailto<P: Printer>(
config: &AccountConfig,
backend: &mut dyn Backend,
sender: &mut dyn Sender,
printer: &mut P,
url: &Url,
) -> Result<()> {
let mut builder = MessageBuilder::new().to(url.path());
for (key, val) in url.query_pairs() {
match key.to_lowercase().as_bytes() {
b"cc" => builder = builder.cc(val.to_string()),
b"bcc" => builder = builder.bcc(val.to_string()),
b"subject" => builder = builder.subject(val),
b"body" => builder = builder.text_body(val),
_ => (),
}
}
let tpl = config
.generate_tpl_interpreter()
.with_show_only_headers(config.email_writing_headers())
.build()
.from_msg_builder(builder)
.await?;
editor::edit_tpl_with_editor(config, printer, backend, sender, tpl).await
}
pub async fn move_<P: Printer>(
printer: &mut P,
id_mapper: &IdMapper,
backend: &mut dyn Backend,
from_folder: &str,
to_folder: &str,
ids: Vec<&str>,
) -> Result<()> {
let ids = id_mapper.get_ids(ids)?;
let ids = ids.iter().map(String::as_str).collect::<Vec<_>>();
backend.move_emails(&from_folder, &to_folder, ids).await?;
printer.print("Email(s) successfully moved!")
}
pub async fn read<P: Printer>(
config: &AccountConfig,
printer: &mut P,
id_mapper: &IdMapper,
backend: &mut dyn Backend,
folder: &str,
ids: Vec<&str>,
text_mime: &str,
raw: bool,
headers: Vec<&str>,
) -> Result<()> {
let ids = id_mapper.get_ids(ids)?;
let ids = ids.iter().map(String::as_str).collect::<Vec<_>>();
let emails = backend.get_emails(&folder, ids).await?;
let mut glue = "";
let mut bodies = String::default();
for email in emails.to_vec() {
bodies.push_str(glue);
if raw {
// emails do not always have valid utf8, uses "lossy" to
// display what can be displayed
bodies.push_str(&String::from_utf8_lossy(email.raw()?).into_owned());
} else {
let tpl: String = email
.to_read_tpl(&config, |tpl| match text_mime {
"html" => tpl
.with_hide_all_headers()
.with_filter_parts(FilterParts::Only("text/html".into())),
_ => tpl.with_show_additional_headers(&headers),
})
.await?
.into();
bodies.push_str(&tpl);
}
glue = "\n\n";
}
printer.print(bodies)
}
pub async fn reply<P: Printer>(
config: &AccountConfig,
printer: &mut P,
id_mapper: &IdMapper,
backend: &mut dyn Backend,
sender: &mut dyn Sender,
folder: &str,
id: &str,
all: bool,
headers: Option<Vec<(&str, &str)>>,
body: Option<&str>,
) -> Result<()> {
let ids = id_mapper.get_ids([id])?;
let ids = ids.iter().map(String::as_str).collect::<Vec<_>>();
let tpl = backend
.get_emails(&folder, ids)
.await?
.first()
.ok_or_else(|| anyhow!("cannot find email {}", id))?
.to_reply_tpl_builder(config)
.with_some_headers(headers)
.with_some_body(body)
.with_reply_all(all)
.build()
.await?;
trace!("initial template: {tpl}");
editor::edit_tpl_with_editor(config, printer, backend, sender, tpl).await?;
backend
.add_flags(&folder, vec![id], &Flags::from_iter([Flag::Answered]))
.await?;
Ok(())
}
pub async fn save<P: Printer>(
printer: &mut P,
id_mapper: &IdMapper,
backend: &mut dyn Backend,
folder: &str,
raw_email: String,
) -> Result<()> {
let is_tty = atty::is(Stream::Stdin);
let is_json = printer.is_json();
let raw_email = if is_tty || is_json {
raw_email.replace("\r", "").replace("\n", "\r\n")
} else {
io::stdin()
.lock()
.lines()
.filter_map(Result::ok)
.collect::<Vec<String>>()
.join("\r\n")
};
let id = backend
.add_email(&folder, raw_email.as_bytes(), &Flags::default())
.await?;
id_mapper.create_alias(id)?;
Ok(())
}
pub async fn search<P: Printer>(
config: &AccountConfig,
printer: &mut P,
id_mapper: &IdMapper,
backend: &mut dyn Backend,
folder: &str,
query: String,
max_width: Option<usize>,
page_size: Option<usize>,
page: usize,
) -> Result<()> {
let page_size = page_size.unwrap_or(config.email_listing_page_size());
let envelopes = Envelopes::from_backend(
config,
id_mapper,
backend
.search_envelopes(&folder, &query, "", page_size, page)
.await?,
)?;
let opts = PrintTableOpts {
format: &config.email_reading_format,
max_width,
};
printer.print_table(Box::new(envelopes), opts)
}
pub async fn sort<P: Printer>(
config: &AccountConfig,
printer: &mut P,
id_mapper: &IdMapper,
backend: &mut dyn Backend,
folder: &str,
sort: String,
query: String,
max_width: Option<usize>,
page_size: Option<usize>,
page: usize,
) -> Result<()> {
let page_size = page_size.unwrap_or(config.email_listing_page_size());
let envelopes = Envelopes::from_backend(
config,
id_mapper,
backend
.search_envelopes(&folder, &query, &sort, page_size, page)
.await?,
)?;
let opts = PrintTableOpts {
format: &config.email_reading_format,
max_width,
};
printer.print_table(Box::new(envelopes), opts)
}
pub async fn send<P: Printer>(
config: &AccountConfig,
printer: &mut P,
backend: &mut dyn Backend,
sender: &mut dyn Sender,
raw_email: String,
) -> Result<()> {
let folder = config.sent_folder_alias()?;
let is_tty = atty::is(Stream::Stdin);
let is_json = printer.is_json();
let raw_email = if is_tty || is_json {
raw_email.replace("\r", "").replace("\n", "\r\n")
} else {
io::stdin()
.lock()
.lines()
.filter_map(Result::ok)
.collect::<Vec<String>>()
.join("\r\n")
};
trace!("raw email: {:?}", raw_email);
sender.send(raw_email.as_bytes()).await?;
if config.email_sending_save_copy {
backend
.add_email(
&folder,
raw_email.as_bytes(),
&Flags::from_iter([Flag::Seen]),
)
.await?;
}
Ok(())
}
pub async fn write<P: Printer>(
config: &AccountConfig,
printer: &mut P,
backend: &mut dyn Backend,
sender: &mut dyn Sender,
headers: Option<Vec<(&str, &str)>>,
body: Option<&str>,
) -> Result<()> {
let tpl = Message::new_tpl_builder(config)
.with_some_headers(headers)
.with_some_body(body)
.build()
.await?;
trace!("initial template: {tpl}");
editor::edit_tpl_with_editor(config, printer, backend, sender, tpl).await?;
Ok(())
}

View file

@ -1,2 +0,0 @@
pub mod args;
pub mod handlers;

View file

@ -1,66 +0,0 @@
use serde::Serialize;
use crate::{
ui::{Cell, Row, Table},
Flag, Flags,
};
#[derive(Clone, Debug, Default, Serialize)]
pub struct Mailbox {
pub name: Option<String>,
pub addr: String,
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct Envelope {
pub id: String,
pub flags: Flags,
pub subject: String,
pub from: Mailbox,
pub date: String,
}
impl Table for Envelope {
fn head() -> Row {
Row::new()
.cell(Cell::new("ID").bold().underline().white())
.cell(Cell::new("FLAGS").bold().underline().white())
.cell(Cell::new("SUBJECT").shrinkable().bold().underline().white())
.cell(Cell::new("FROM").bold().underline().white())
.cell(Cell::new("DATE").bold().underline().white())
}
fn row(&self) -> Row {
let id = self.id.to_string();
let unseen = !self.flags.contains(&Flag::Seen);
let flags = {
let mut flags = String::new();
flags.push_str(if !unseen { " " } else { "" });
flags.push_str(if self.flags.contains(&Flag::Answered) {
""
} else {
" "
});
flags.push_str(if self.flags.contains(&Flag::Flagged) {
""
} else {
" "
});
flags
};
let subject = &self.subject;
let sender = if let Some(name) = &self.from.name {
name
} else {
&self.from.addr
};
let date = &self.date;
Row::new()
.cell(Cell::new(id).bold_if(unseen).red())
.cell(Cell::new(flags).bold_if(unseen).white())
.cell(Cell::new(subject).shrinkable().bold_if(unseen).green())
.cell(Cell::new(sender).bold_if(unseen).blue())
.cell(Cell::new(date).bold_if(unseen).yellow())
}
}

View file

@ -1,5 +0,0 @@
pub mod envelope;
pub mod envelopes;
pub use envelope::*;
pub use envelopes::*;

View file

@ -1,105 +0,0 @@
//! Email flag CLI module.
//!
//! This module contains the command matcher, the subcommands and the
//! arguments related to the email flag domain.
use ::email::email::{Flag, Flags};
use anyhow::Result;
use clap::{Arg, ArgMatches, Command};
use log::{debug, info};
use crate::email;
const ARG_FLAGS: &str = "flag";
const CMD_ADD: &str = "add";
const CMD_REMOVE: &str = "remove";
const CMD_SET: &str = "set";
pub(crate) const CMD_FLAG: &str = "flags";
/// Represents the flag commands.
#[derive(Debug, PartialEq, Eq)]
pub enum Cmd<'a> {
Add(email::args::Ids<'a>, Flags),
Remove(email::args::Ids<'a>, Flags),
Set(email::args::Ids<'a>, Flags),
}
/// Represents the flag command matcher.
pub fn matches<'a>(m: &'a ArgMatches) -> Result<Option<Cmd<'a>>> {
let cmd = if let Some(m) = m.subcommand_matches(CMD_ADD) {
debug!("add flags command matched");
let ids = email::args::parse_ids_arg(m);
let flags = parse_flags_arg(m);
Some(Cmd::Add(ids, flags))
} else if let Some(m) = m.subcommand_matches(CMD_REMOVE) {
info!("remove flags command matched");
let ids = email::args::parse_ids_arg(m);
let flags = parse_flags_arg(m);
Some(Cmd::Remove(ids, flags))
} else if let Some(m) = m.subcommand_matches(CMD_SET) {
debug!("set flags command matched");
let ids = email::args::parse_ids_arg(m);
let flags = parse_flags_arg(m);
Some(Cmd::Set(ids, flags))
} else {
None
};
Ok(cmd)
}
/// Represents the flag subcommands.
pub fn subcmds<'a>() -> Vec<Command> {
vec![Command::new(CMD_FLAG)
.about("Handles email flags")
.subcommand_required(true)
.arg_required_else_help(true)
.subcommand(
Command::new(CMD_ADD)
.about("Adds flags to an email")
.arg(email::args::ids_arg())
.arg(flags_arg()),
)
.subcommand(
Command::new(CMD_REMOVE)
.aliases(["delete", "del", "d"])
.about("Removes flags from an email")
.arg(email::args::ids_arg())
.arg(flags_arg()),
)
.subcommand(
Command::new(CMD_SET)
.aliases(["change", "c"])
.about("Sets flags of an email")
.arg(email::args::ids_arg())
.arg(flags_arg()),
)]
}
/// Represents the flags argument.
pub fn flags_arg() -> Arg {
Arg::new(ARG_FLAGS)
.value_name("FLAGS")
.help("The flags")
.long_help(
"The list of flags.
It can be one of: seen, answered, flagged, deleted, or draft.
Other flags are considered custom.",
)
.num_args(1..)
.required(true)
.last(true)
}
/// Represents the flags argument parser.
pub fn parse_flags_arg(matches: &ArgMatches) -> Flags {
Flags::from_iter(
matches
.get_many::<String>(ARG_FLAGS)
.unwrap_or_default()
.map(String::as_str)
.map(Flag::from),
)
}

View file

@ -1,26 +0,0 @@
use serde::Serialize;
/// Represents the flag variants.
#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize)]
pub enum Flag {
Seen,
Answered,
Flagged,
Deleted,
Draft,
Custom(String),
}
impl From<&email::email::Flag> for Flag {
fn from(flag: &email::email::Flag) -> Self {
use email::email::Flag::*;
match flag {
Seen => Flag::Seen,
Answered => Flag::Answered,
Flagged => Flag::Flagged,
Deleted => Flag::Deleted,
Draft => Flag::Draft,
Custom(flag) => Flag::Custom(flag.clone()),
}
}
}

View file

@ -1,21 +0,0 @@
use serde::Serialize;
use std::{collections::HashSet, ops};
use crate::Flag;
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
pub struct Flags(pub HashSet<Flag>);
impl ops::Deref for Flags {
type Target = HashSet<Flag>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl From<email::email::Flags> for Flags {
fn from(flags: email::email::Flags) -> Self {
Flags(flags.iter().map(Flag::from).collect())
}
}

View file

@ -1,46 +0,0 @@
use anyhow::Result;
use email::{backend::Backend, email::Flags};
use crate::{printer::Printer, IdMapper};
pub async fn add<P: Printer>(
printer: &mut P,
id_mapper: &IdMapper,
backend: &mut dyn Backend,
folder: &str,
ids: Vec<&str>,
flags: &Flags,
) -> Result<()> {
let ids = id_mapper.get_ids(ids)?;
let ids = ids.iter().map(String::as_str).collect::<Vec<_>>();
backend.add_flags(folder, ids, flags).await?;
printer.print("Flag(s) successfully added!")
}
pub async fn set<P: Printer>(
printer: &mut P,
id_mapper: &IdMapper,
backend: &mut dyn Backend,
folder: &str,
ids: Vec<&str>,
flags: &Flags,
) -> Result<()> {
let ids = id_mapper.get_ids(ids)?;
let ids = ids.iter().map(String::as_str).collect::<Vec<_>>();
backend.set_flags(folder, ids, flags).await?;
printer.print("Flag(s) successfully set!")
}
pub async fn remove<P: Printer>(
printer: &mut P,
id_mapper: &IdMapper,
backend: &mut dyn Backend,
folder: &str,
ids: Vec<&str>,
flags: &Flags,
) -> Result<()> {
let ids = id_mapper.get_ids(ids)?;
let ids = ids.iter().map(String::as_str).collect::<Vec<_>>();
backend.remove_flags(folder, ids, flags).await?;
printer.print("Flag(s) successfully removed!")
}

View file

@ -1,8 +0,0 @@
pub mod args;
pub mod handlers;
pub mod flag;
pub use flag::*;
pub mod flags;
pub use flags::*;

View file

@ -1,237 +0,0 @@
//! Folder CLI module.
//!
//! This module provides subcommands, arguments and a command matcher
//! related to the folder domain.
use std::collections::HashSet;
use anyhow::Result;
use clap::{self, Arg, ArgAction, ArgMatches, Command};
use log::{debug, info};
use crate::ui::table;
const ARG_ALL: &str = "all";
const ARG_EXCLUDE: &str = "exclude";
const ARG_INCLUDE: &str = "include";
const ARG_SOURCE: &str = "source";
const ARG_TARGET: &str = "target";
const CMD_CREATE: &str = "create";
const CMD_DELETE: &str = "delete";
const CMD_EXPUNGE: &str = "expunge";
const CMD_FOLDERS: &str = "folders";
const CMD_LIST: &str = "list";
/// Represents the folder commands.
#[derive(Debug, PartialEq, Eq)]
pub enum Cmd {
Create,
List(table::args::MaxTableWidth),
Expunge,
Delete,
}
/// Represents the folder command matcher.
pub fn matches(m: &ArgMatches) -> Result<Option<Cmd>> {
let cmd = if let Some(m) = m.subcommand_matches(CMD_FOLDERS) {
if let Some(_) = m.subcommand_matches(CMD_EXPUNGE) {
info!("expunge folder subcommand matched");
Some(Cmd::Expunge)
} else if let Some(_) = m.subcommand_matches(CMD_CREATE) {
debug!("create folder command matched");
Some(Cmd::Create)
} else if let Some(m) = m.subcommand_matches(CMD_LIST) {
debug!("list folders command matched");
let max_table_width = table::args::parse_max_width(m);
Some(Cmd::List(max_table_width))
} else if let Some(_) = m.subcommand_matches(CMD_DELETE) {
debug!("delete folder command matched");
Some(Cmd::Delete)
} else {
info!("no folder subcommand matched, falling back to subcommand list");
Some(Cmd::List(None))
}
} else {
None
};
Ok(cmd)
}
/// Represents the folder subcommand.
pub fn subcmd() -> Command {
Command::new(CMD_FOLDERS)
.about("Manage folders")
.subcommands([
Command::new(CMD_EXPUNGE).about("Delete emails marked for deletion"),
Command::new(CMD_CREATE)
.aliases(["add", "new"])
.about("Create a new folder"),
Command::new(CMD_LIST)
.about("List folders")
.arg(table::args::max_width()),
Command::new(CMD_DELETE)
.aliases(["remove", "rm"])
.about("Delete a folder with all its emails"),
])
}
/// Represents the source folder argument.
pub fn source_arg() -> Arg {
Arg::new(ARG_SOURCE)
.help("Set the source folder")
.long("folder")
.short('f')
.global(true)
.value_name("SOURCE")
}
/// Represents the source folder argument parser.
pub fn parse_source_arg(matches: &ArgMatches) -> Option<&str> {
matches.get_one::<String>(ARG_SOURCE).map(String::as_str)
}
/// Represents the all folders argument.
pub fn all_arg(help: &'static str) -> Arg {
Arg::new(ARG_ALL)
.help(help)
.long("all-folders")
.alias("all")
.short('A')
.action(ArgAction::SetTrue)
.conflicts_with(ARG_SOURCE)
.conflicts_with(ARG_INCLUDE)
.conflicts_with(ARG_EXCLUDE)
}
/// Represents the all folders argument parser.
pub fn parse_all_arg(m: &ArgMatches) -> bool {
m.get_flag(ARG_ALL)
}
/// Represents the folders to include argument.
pub fn include_arg(help: &'static str) -> Arg {
Arg::new(ARG_INCLUDE)
.help(help)
.long("include-folder")
.alias("only")
.short('F')
.value_name("FOLDER")
.num_args(1..)
.action(ArgAction::Append)
.conflicts_with(ARG_SOURCE)
.conflicts_with(ARG_ALL)
.conflicts_with(ARG_EXCLUDE)
}
/// Represents the folders to include argument parser.
pub fn parse_include_arg(m: &ArgMatches) -> HashSet<String> {
m.get_many::<String>(ARG_INCLUDE)
.unwrap_or_default()
.map(ToOwned::to_owned)
.collect()
}
/// Represents the folders to exclude argument.
pub fn exclude_arg(help: &'static str) -> Arg {
Arg::new(ARG_EXCLUDE)
.help(help)
.long("exclude-folder")
.alias("except")
.short('x')
.value_name("FOLDER")
.num_args(1..)
.action(ArgAction::Append)
.conflicts_with(ARG_SOURCE)
.conflicts_with(ARG_ALL)
.conflicts_with(ARG_INCLUDE)
}
/// Represents the folders to exclude argument parser.
pub fn parse_exclude_arg(m: &ArgMatches) -> HashSet<String> {
m.get_many::<String>(ARG_EXCLUDE)
.unwrap_or_default()
.map(ToOwned::to_owned)
.collect()
}
/// Represents the target folder argument.
pub fn target_arg() -> Arg {
Arg::new(ARG_TARGET)
.help("Specifies the target folder")
.value_name("TARGET")
.required(true)
}
/// Represents the target folder argument parser.
pub fn parse_target_arg(matches: &ArgMatches) -> &str {
matches.get_one::<String>(ARG_TARGET).unwrap().as_str()
}
#[cfg(test)]
mod tests {
use clap::{error::ErrorKind, Command};
use super::*;
#[test]
fn it_should_match_cmds() {
let arg = Command::new("himalaya")
.subcommand(subcmd())
.get_matches_from(&["himalaya", "folders"]);
assert_eq!(Some(Cmd::List(None)), matches(&arg).unwrap());
let arg = Command::new("himalaya")
.subcommand(subcmd())
.get_matches_from(&["himalaya", "folders", "list", "--max-width", "20"]);
assert_eq!(Some(Cmd::List(Some(20))), matches(&arg).unwrap());
}
#[test]
fn it_should_match_source_arg() {
macro_rules! get_matches_from {
($($arg:expr),*) => {
Command::new("himalaya")
.arg(source_arg())
.get_matches_from(&["himalaya", $($arg,)*])
};
}
let app = get_matches_from![];
assert_eq!(None, app.get_one::<String>(ARG_SOURCE).map(String::as_str));
let app = get_matches_from!["-f", "SOURCE"];
assert_eq!(
Some("SOURCE"),
app.get_one::<String>(ARG_SOURCE).map(String::as_str)
);
let app = get_matches_from!["--folder", "SOURCE"];
assert_eq!(
Some("SOURCE"),
app.get_one::<String>(ARG_SOURCE).map(String::as_str)
);
}
#[test]
fn it_should_match_target_arg() {
macro_rules! get_matches_from {
($($arg:expr),*) => {
Command::new("himalaya")
.arg(target_arg())
.try_get_matches_from_mut(&["himalaya", $($arg,)*])
};
}
let app = get_matches_from![];
assert_eq!(ErrorKind::MissingRequiredArgument, app.unwrap_err().kind());
let app = get_matches_from!["TARGET"];
assert_eq!(
Some("TARGET"),
app.unwrap()
.get_one::<String>(ARG_TARGET)
.map(String::as_str)
);
}
}

View file

@ -1,32 +0,0 @@
use serde::Serialize;
use crate::ui::{Cell, Row, Table};
#[derive(Clone, Debug, Default, Serialize)]
pub struct Folder {
pub name: String,
pub desc: String,
}
impl From<&email::folder::Folder> for Folder {
fn from(folder: &email::folder::Folder) -> Self {
Folder {
name: folder.name.clone(),
desc: folder.desc.clone(),
}
}
}
impl Table for Folder {
fn head() -> Row {
Row::new()
.cell(Cell::new("NAME").bold().underline().white())
.cell(Cell::new("DESC").bold().underline().white())
}
fn row(&self) -> Row {
Row::new()
.cell(Cell::new(&self.name).blue())
.cell(Cell::new(&self.desc).green())
}
}

View file

@ -1,35 +0,0 @@
use anyhow::Result;
use serde::Serialize;
use std::ops;
use crate::{
printer::{PrintTable, PrintTableOpts, WriteColor},
ui::Table,
Folder,
};
#[derive(Clone, Debug, Default, Serialize)]
pub struct Folders(Vec<Folder>);
impl ops::Deref for Folders {
type Target = Vec<Folder>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl From<email::folder::Folders> for Folders {
fn from(folders: email::folder::Folders) -> Self {
Folders(folders.iter().map(Folder::from).collect())
}
}
impl PrintTable for Folders {
fn print_table(&self, writer: &mut dyn WriteColor, opts: PrintTableOpts) -> Result<()> {
writeln!(writer)?;
Table::print(writer, self, opts)?;
writeln!(writer)?;
Ok(())
}
}

View file

@ -1,8 +0,0 @@
pub mod folder;
pub use folder::*;
pub mod folders;
pub use folders::*;
pub mod args;
pub mod handlers;

View file

@ -1,16 +0,0 @@
pub mod account;
pub mod backend;
pub mod email;
pub mod envelope;
pub mod flag;
pub mod folder;
pub mod sender;
pub mod tpl;
pub use self::account::{args, handlers, Account, Accounts};
pub use self::backend::*;
pub use self::email::*;
pub use self::envelope::*;
pub use self::flag::*;
pub use self::folder::*;
pub use self::tpl::*;

View file

@ -1,4 +0,0 @@
pub mod sendmail;
#[cfg(feature = "smtp-sender")]
pub mod smtp;
pub(crate) mod wizard;

View file

@ -1,36 +0,0 @@
use anyhow::Result;
use dialoguer::Select;
use email::sender::SenderConfig;
use crate::config::wizard::THEME;
use super::sendmail;
#[cfg(feature = "smtp-sender")]
use super::smtp;
#[cfg(feature = "smtp-sender")]
const SMTP: &str = "SMTP";
const SENDMAIL: &str = "Sendmail";
const NONE: &str = "None";
const SENDERS: &[&str] = &[
#[cfg(feature = "smtp-sender")]
SMTP,
SENDMAIL,
NONE,
];
pub(crate) async fn configure(account_name: &str, email: &str) -> Result<SenderConfig> {
let sender = Select::with_theme(&*THEME)
.with_prompt("Email sender")
.items(SENDERS)
.default(0)
.interact_opt()?;
match sender {
#[cfg(feature = "smtp-sender")]
Some(n) if SENDERS[n] == SMTP => smtp::wizard::configure(account_name, email).await,
Some(n) if SENDERS[n] == SENDMAIL => sendmail::wizard::configure(),
_ => Ok(SenderConfig::None),
}
}

View file

@ -1,148 +0,0 @@
//! Module related to email template CLI.
//!
//! This module provides subcommands, arguments and a command matcher
//! related to email templating.
use anyhow::Result;
use clap::{Arg, ArgAction, ArgMatches, Command};
use log::warn;
use crate::email;
const ARG_BODY: &str = "body";
const ARG_HEADERS: &str = "headers";
const ARG_TPL: &str = "template";
const CMD_FORWARD: &str = "forward";
const CMD_REPLY: &str = "reply";
const CMD_SAVE: &str = "save";
const CMD_SEND: &str = "send";
const CMD_WRITE: &str = "write";
pub const CMD_TPL: &str = "template";
pub type RawTpl = String;
pub type Headers<'a> = Option<Vec<(&'a str, &'a str)>>;
pub type Body<'a> = Option<&'a str>;
/// Represents the template commands.
#[derive(Debug, PartialEq, Eq)]
pub enum Cmd<'a> {
Forward(email::args::Id<'a>, Headers<'a>, Body<'a>),
Write(Headers<'a>, Body<'a>),
Reply(email::args::Id<'a>, email::args::All, Headers<'a>, Body<'a>),
Save(RawTpl),
Send(RawTpl),
}
/// Represents the template command matcher.
pub fn matches<'a>(m: &'a ArgMatches) -> Result<Option<Cmd<'a>>> {
let cmd = if let Some(m) = m.subcommand_matches(CMD_FORWARD) {
let id = email::args::parse_id_arg(m);
let headers = parse_headers_arg(m);
let body = parse_body_arg(m);
Some(Cmd::Forward(id, headers, body))
} else if let Some(m) = m.subcommand_matches(CMD_REPLY) {
let id = email::args::parse_id_arg(m);
let all = email::args::parse_reply_all_flag(m);
let headers = parse_headers_arg(m);
let body = parse_body_arg(m);
Some(Cmd::Reply(id, all, headers, body))
} else if let Some(m) = m.subcommand_matches(CMD_SAVE) {
let raw_tpl = parse_raw_arg(m);
Some(Cmd::Save(raw_tpl))
} else if let Some(m) = m.subcommand_matches(CMD_SEND) {
let raw_tpl = parse_raw_arg(m);
Some(Cmd::Send(raw_tpl))
} else if let Some(m) = m.subcommand_matches(CMD_WRITE) {
let headers = parse_headers_arg(m);
let body = parse_body_arg(m);
Some(Cmd::Write(headers, body))
} else {
None
};
Ok(cmd)
}
/// Represents the template subcommands.
pub fn subcmds<'a>() -> Vec<Command> {
vec![Command::new(CMD_TPL)
.alias("tpl")
.about("Handles email templates")
.subcommand_required(true)
.arg_required_else_help(true)
.subcommand(
Command::new(CMD_FORWARD)
.alias("fwd")
.about("Generates a template for forwarding an email")
.arg(email::args::id_arg())
.args(&args()),
)
.subcommand(
Command::new(CMD_REPLY)
.about("Generates a template for replying to an email")
.arg(email::args::id_arg())
.arg(email::args::reply_all_flag())
.args(&args()),
)
.subcommand(
Command::new(CMD_SAVE)
.about("Compiles the template into a valid email then saves it")
.arg(Arg::new(ARG_TPL).raw(true)),
)
.subcommand(
Command::new(CMD_SEND)
.about("Compiles the template into a valid email then sends it")
.arg(Arg::new(ARG_TPL).raw(true)),
)
.subcommand(
Command::new(CMD_WRITE)
.aliases(["new", "n"])
.about("Generates a template for writing a new email")
.args(&args()),
)]
}
/// Represents the template arguments.
pub fn args() -> Vec<Arg> {
vec![
Arg::new(ARG_HEADERS)
.help("Overrides a specific header")
.short('H')
.long("header")
.value_name("KEY:VAL")
.action(ArgAction::Append),
Arg::new(ARG_BODY)
.help("Overrides the body")
.short('B')
.long("body")
.value_name("STRING"),
]
}
/// Represents the template headers argument parser.
pub fn parse_headers_arg(m: &ArgMatches) -> Headers<'_> {
m.get_many::<String>(ARG_HEADERS).map(|h| {
h.filter_map(|h| match h.split_once(':') {
Some((key, val)) => Some((key, val.trim())),
None => {
warn!("invalid raw header {h:?}, skipping it");
None
}
})
.collect()
})
}
/// Represents the template body argument parser.
pub fn parse_body_arg(matches: &ArgMatches) -> Body<'_> {
matches.get_one::<String>(ARG_BODY).map(String::as_str)
}
/// Represents the raw template argument parser.
pub fn parse_raw_arg(matches: &ArgMatches) -> RawTpl {
matches
.get_one::<String>(ARG_TPL)
.cloned()
.unwrap_or_default()
}

View file

@ -1,157 +0,0 @@
use anyhow::{anyhow, Result};
use atty::Stream;
use email::{
account::AccountConfig,
backend::Backend,
email::{Flag, Flags, Message},
sender::Sender,
};
use mml::MmlCompilerBuilder;
use std::io::{stdin, BufRead};
use crate::{printer::Printer, IdMapper};
pub async fn forward<P: Printer>(
config: &AccountConfig,
printer: &mut P,
id_mapper: &IdMapper,
backend: &mut dyn Backend,
folder: &str,
id: &str,
headers: Option<Vec<(&str, &str)>>,
body: Option<&str>,
) -> Result<()> {
let ids = id_mapper.get_ids([id])?;
let ids = ids.iter().map(String::as_str).collect::<Vec<_>>();
let tpl: String = backend
.get_emails(folder, ids)
.await?
.first()
.ok_or_else(|| anyhow!("cannot find email {}", id))?
.to_forward_tpl_builder(config)
.with_some_headers(headers)
.with_some_body(body)
.build()
.await?
.into();
printer.print(tpl)
}
pub async fn reply<P: Printer>(
config: &AccountConfig,
printer: &mut P,
id_mapper: &IdMapper,
backend: &mut dyn Backend,
folder: &str,
id: &str,
all: bool,
headers: Option<Vec<(&str, &str)>>,
body: Option<&str>,
) -> Result<()> {
let ids = id_mapper.get_ids([id])?;
let ids = ids.iter().map(String::as_str).collect::<Vec<_>>();
let tpl: String = backend
.get_emails(folder, ids)
.await?
.first()
.ok_or_else(|| anyhow!("cannot find email {}", id))?
.to_reply_tpl_builder(config)
.with_some_headers(headers)
.with_some_body(body)
.with_reply_all(all)
.build()
.await?
.into();
printer.print(tpl)
}
pub async fn save<P: Printer>(
#[allow(unused_variables)] config: &AccountConfig,
printer: &mut P,
id_mapper: &IdMapper,
backend: &mut dyn Backend,
folder: &str,
tpl: String,
) -> Result<()> {
let tpl = if atty::is(Stream::Stdin) || printer.is_json() {
tpl.replace("\r", "")
} else {
stdin()
.lock()
.lines()
.filter_map(Result::ok)
.collect::<Vec<String>>()
.join("\n")
};
let compiler = MmlCompilerBuilder::new();
#[cfg(feature = "pgp")]
let compiler = compiler.with_pgp(config.pgp.clone());
let email = compiler.build(tpl.as_str())?.compile().await?.into_vec()?;
let id = backend.add_email(folder, &email, &Flags::default()).await?;
id_mapper.create_alias(id)?;
printer.print("Template successfully saved!")
}
pub async fn send<P: Printer>(
config: &AccountConfig,
printer: &mut P,
backend: &mut dyn Backend,
sender: &mut dyn Sender,
tpl: String,
) -> Result<()> {
let folder = config.sent_folder_alias()?;
let tpl = if atty::is(Stream::Stdin) || printer.is_json() {
tpl.replace("\r", "")
} else {
stdin()
.lock()
.lines()
.filter_map(Result::ok)
.collect::<Vec<String>>()
.join("\n")
};
let compiler = MmlCompilerBuilder::new();
#[cfg(feature = "pgp")]
let compiler = compiler.with_pgp(config.pgp.clone());
let email = compiler.build(tpl.as_str())?.compile().await?.into_vec()?;
sender.send(&email).await?;
if config.email_sending_save_copy {
backend
.add_email(&folder, &email, &Flags::from_iter([Flag::Seen]))
.await?;
}
printer.print("Template successfully sent!")?;
Ok(())
}
pub async fn write<P: Printer>(
config: &AccountConfig,
printer: &mut P,
headers: Option<Vec<(&str, &str)>>,
body: Option<&str>,
) -> Result<()> {
let tpl: String = Message::new_tpl_builder(config)
.with_some_headers(headers)
.with_some_body(body)
.build()
.await?
.into();
printer.print(tpl)
}

View file

@ -1,2 +0,0 @@
pub mod args;
pub mod handlers;

View file

@ -0,0 +1,17 @@
use clap::Parser;
/// The envelope id argument parser.
#[derive(Debug, Parser)]
pub struct EnvelopeIdArg {
/// The envelope id.
#[arg(value_name = "ID", required = true)]
pub id: usize,
}
/// The envelopes ids arguments parser.
#[derive(Debug, Parser)]
pub struct EnvelopeIdsArgs {
/// The list of envelopes ids.
#[arg(value_name = "ID", required = true)]
pub ids: Vec<usize>,
}

View file

@ -0,0 +1 @@
pub mod ids;

View file

@ -0,0 +1,76 @@
use anyhow::Result;
use clap::Parser;
use log::info;
use crate::{
account::arg::name::AccountNameFlag,
backend::Backend,
cache::arg::disable::CacheDisableFlag,
config::TomlConfig,
folder::arg::name::FolderNameOptionalArg,
printer::{PrintTableOpts, Printer},
ui::arg::max_width::TableMaxWidthFlag,
};
/// List all envelopes.
///
/// This command allows you to list all envelopes included in the
/// given folder.
#[derive(Debug, Parser)]
pub struct EnvelopeListCommand {
#[command(flatten)]
pub folder: FolderNameOptionalArg,
/// The page number.
///
/// The page number starts from 1 (which is the default). Giving a
/// page number to big will result in a out of bound error.
#[arg(long, short, value_name = "NUMBER", default_value = "1")]
pub page: usize,
/// The page size.
///
/// Determine the amount of envelopes a page should contain.
#[arg(long, short = 's', value_name = "NUMBER")]
pub page_size: Option<usize>,
#[command(flatten)]
pub table: TableMaxWidthFlag,
#[command(flatten)]
pub cache: CacheDisableFlag,
#[command(flatten)]
pub account: AccountNameFlag,
}
impl EnvelopeListCommand {
pub async fn execute(self, printer: &mut impl Printer, config: &TomlConfig) -> Result<()> {
info!("executing envelope list command");
let folder = &self.folder.name;
let some_account_name = self.account.name.as_ref().map(String::as_str);
let (toml_account_config, account_config) = config
.clone()
.into_account_configs(some_account_name, self.cache.disable)?;
let backend = Backend::new(toml_account_config, account_config.clone(), false).await?;
let page_size = self
.page_size
.unwrap_or(account_config.get_envelope_list_page_size());
let page = 1.max(self.page) - 1;
let envelopes = backend.list_envelopes(folder, page_size, page).await?;
printer.print_table(
Box::new(envelopes),
PrintTableOpts {
format: &account_config.get_message_read_format(),
max_width: self.table.max_width,
},
)?;
Ok(())
}
}

View file

@ -0,0 +1,28 @@
pub mod list;
use anyhow::Result;
use clap::Subcommand;
use crate::{config::TomlConfig, printer::Printer};
use self::list::EnvelopeListCommand;
/// Manage envelopes.
///
/// An envelope is a small representation of a message. It contains an
/// identifier (given by the backend), some flags as well as few
/// headers from the message itself. This subcommand allows you to
/// manage them.
#[derive(Debug, Subcommand)]
pub enum EnvelopeSubcommand {
#[command(alias = "lst")]
List(EnvelopeListCommand),
}
impl EnvelopeSubcommand {
pub async fn execute(self, printer: &mut impl Printer, config: &TomlConfig) -> Result<()> {
match self {
Self::List(cmd) => cmd.execute(printer, config).await,
}
}
}

View file

@ -0,0 +1,63 @@
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use crate::backend::BackendKind;
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
pub struct EnvelopeConfig {
pub list: Option<EnvelopeListConfig>,
pub get: Option<EnvelopeGetConfig>,
}
impl EnvelopeConfig {
pub fn get_used_backends(&self) -> HashSet<&BackendKind> {
let mut kinds = HashSet::default();
if let Some(list) = &self.list {
kinds.extend(list.get_used_backends());
}
if let Some(get) = &self.get {
kinds.extend(get.get_used_backends());
}
kinds
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
pub struct EnvelopeListConfig {
pub backend: Option<BackendKind>,
#[serde(flatten)]
pub remote: email::envelope::list::config::EnvelopeListConfig,
}
impl EnvelopeListConfig {
pub fn get_used_backends(&self) -> HashSet<&BackendKind> {
let mut kinds = HashSet::default();
if let Some(kind) = &self.backend {
kinds.insert(kind);
}
kinds
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
pub struct EnvelopeGetConfig {
pub backend: Option<BackendKind>,
}
impl EnvelopeGetConfig {
pub fn get_used_backends(&self) -> HashSet<&BackendKind> {
let mut kinds = HashSet::default();
if let Some(kind) = &self.backend {
kinds.insert(kind);
}
kinds
}
}

View file

@ -0,0 +1,48 @@
use clap::Parser;
use email::flag::{Flag, Flags};
use log::debug;
/// The ids and/or flags arguments parser.
#[derive(Debug, Parser)]
pub struct IdsAndFlagsArgs {
/// The list of ids and/or flags.
///
/// Every argument that can be parsed as an integer is considered
/// an id, otherwise it is considered as a flag.
#[arg(value_name = "ID-OR-FLAG", required = true)]
pub ids_and_flags: Vec<IdOrFlag>,
}
#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd)]
pub enum IdOrFlag {
Id(usize),
Flag(Flag),
}
impl From<&str> for IdOrFlag {
fn from(value: &str) -> Self {
value.parse::<usize>().map(Self::Id).unwrap_or_else(|err| {
let flag = Flag::from(value);
debug!("cannot parse {value} as usize, parsing it as flag {flag}");
debug!("{err:?}");
Self::Flag(flag)
})
}
}
pub fn into_tuple(ids_and_flags: &[IdOrFlag]) -> (Vec<usize>, Flags) {
ids_and_flags.iter().fold(
(Vec::default(), Flags::default()),
|(mut ids, mut flags), arg| {
match arg {
IdOrFlag::Id(id) => {
ids.push(*id);
}
IdOrFlag::Flag(flag) => {
flags.insert(flag.to_owned());
}
};
(ids, flags)
},
)
}

View file

@ -0,0 +1 @@
pub mod ids_and_flags;

View file

@ -0,0 +1,51 @@
use anyhow::Result;
use clap::Parser;
use log::info;
use crate::{
account::arg::name::AccountNameFlag,
backend::Backend,
cache::arg::disable::CacheDisableFlag,
config::TomlConfig,
flag::arg::ids_and_flags::{into_tuple, IdsAndFlagsArgs},
folder::arg::name::FolderNameOptionalFlag,
printer::Printer,
};
/// Add flag(s) to an envelope.
///
/// This command allows you to attach the given flag(s) to the given
/// envelope(s).
#[derive(Debug, Parser)]
pub struct FlagAddCommand {
#[command(flatten)]
pub folder: FolderNameOptionalFlag,
#[command(flatten)]
pub args: IdsAndFlagsArgs,
#[command(flatten)]
pub cache: CacheDisableFlag,
#[command(flatten)]
pub account: AccountNameFlag,
}
impl FlagAddCommand {
pub async fn execute(self, printer: &mut impl Printer, config: &TomlConfig) -> Result<()> {
info!("executing flag add command");
let folder = &self.folder.name;
let account = self.account.name.as_ref().map(String::as_str);
let cache = self.cache.disable;
let (toml_account_config, account_config) =
config.clone().into_account_configs(account, cache)?;
let backend = Backend::new(toml_account_config, account_config.clone(), false).await?;
let (ids, flags) = into_tuple(&self.args.ids_and_flags);
backend.add_flags(folder, &ids, &flags).await?;
printer.print(format!("Flag(s) {flags} successfully added!"))
}
}

View file

@ -0,0 +1,41 @@
mod add;
mod remove;
mod set;
use anyhow::Result;
use clap::Subcommand;
use crate::{config::TomlConfig, printer::Printer};
use self::{add::FlagAddCommand, remove::FlagRemoveCommand, set::FlagSetCommand};
/// Manage flags.
///
/// A flag is a tag associated to an envelope. Existing flags are
/// seen, answered, flagged, deleted, draft. Other flags are
/// considered custom, which are not always supported (the
/// synchronization does not take care of them yet).
#[derive(Debug, Subcommand)]
pub enum FlagSubcommand {
#[command(arg_required_else_help = true)]
#[command(alias = "create")]
Add(FlagAddCommand),
#[command(arg_required_else_help = true)]
#[command(aliases = ["update", "change", "replace"])]
Set(FlagSetCommand),
#[command(arg_required_else_help = true)]
#[command(aliases = ["rm", "delete", "del"])]
Remove(FlagRemoveCommand),
}
impl FlagSubcommand {
pub async fn execute(self, printer: &mut impl Printer, config: &TomlConfig) -> Result<()> {
match self {
Self::Add(cmd) => cmd.execute(printer, config).await,
Self::Set(cmd) => cmd.execute(printer, config).await,
Self::Remove(cmd) => cmd.execute(printer, config).await,
}
}
}

View file

@ -0,0 +1,51 @@
use anyhow::Result;
use clap::Parser;
use log::info;
use crate::{
account::arg::name::AccountNameFlag,
backend::Backend,
cache::arg::disable::CacheDisableFlag,
config::TomlConfig,
flag::arg::ids_and_flags::{into_tuple, IdsAndFlagsArgs},
folder::arg::name::FolderNameOptionalFlag,
printer::Printer,
};
/// Remove flag(s) from an envelope.
///
/// This command allows you to remove the given flag(s) from the given
/// envelope(s).
#[derive(Debug, Parser)]
pub struct FlagRemoveCommand {
#[command(flatten)]
pub folder: FolderNameOptionalFlag,
#[command(flatten)]
pub args: IdsAndFlagsArgs,
#[command(flatten)]
pub cache: CacheDisableFlag,
#[command(flatten)]
pub account: AccountNameFlag,
}
impl FlagRemoveCommand {
pub async fn execute(self, printer: &mut impl Printer, config: &TomlConfig) -> Result<()> {
info!("executing flag remove command");
let folder = &self.folder.name;
let account = self.account.name.as_ref().map(String::as_str);
let cache = self.cache.disable;
let (toml_account_config, account_config) =
config.clone().into_account_configs(account, cache)?;
let backend = Backend::new(toml_account_config, account_config.clone(), false).await?;
let (ids, flags) = into_tuple(&self.args.ids_and_flags);
backend.remove_flags(folder, &ids, &flags).await?;
printer.print(format!("Flag(s) {flags} successfully removed!"))
}
}

View file

@ -0,0 +1,51 @@
use anyhow::Result;
use clap::Parser;
use log::info;
use crate::{
account::arg::name::AccountNameFlag,
backend::Backend,
cache::arg::disable::CacheDisableFlag,
config::TomlConfig,
flag::arg::ids_and_flags::{into_tuple, IdsAndFlagsArgs},
folder::arg::name::FolderNameOptionalFlag,
printer::Printer,
};
/// Replace flag(s) of an envelope.
///
/// This command allows you to replace existing flags of the given
/// envelope(s) with the given flag(s).
#[derive(Debug, Parser)]
pub struct FlagSetCommand {
#[command(flatten)]
pub folder: FolderNameOptionalFlag,
#[command(flatten)]
pub args: IdsAndFlagsArgs,
#[command(flatten)]
pub cache: CacheDisableFlag,
#[command(flatten)]
pub account: AccountNameFlag,
}
impl FlagSetCommand {
pub async fn execute(self, printer: &mut impl Printer, config: &TomlConfig) -> Result<()> {
info!("executing flag set command");
let folder = &self.folder.name;
let account = self.account.name.as_ref().map(String::as_str);
let cache = self.cache.disable;
let (toml_account_config, account_config) =
config.clone().into_account_configs(account, cache)?;
let backend = Backend::new(toml_account_config, account_config.clone(), false).await?;
let (ids, flags) = into_tuple(&self.args.ids_and_flags);
backend.set_flags(folder, &ids, &flags).await?;
printer.print(format!("Flag(s) {flags} successfully set!"))
}
}

View file

@ -0,0 +1,82 @@
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use crate::backend::BackendKind;
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
pub struct FlagConfig {
pub add: Option<FlagAddConfig>,
pub set: Option<FlagSetConfig>,
pub remove: Option<FlagRemoveConfig>,
}
impl FlagConfig {
pub fn get_used_backends(&self) -> HashSet<&BackendKind> {
let mut kinds = HashSet::default();
if let Some(add) = &self.add {
kinds.extend(add.get_used_backends());
}
if let Some(set) = &self.set {
kinds.extend(set.get_used_backends());
}
if let Some(remove) = &self.remove {
kinds.extend(remove.get_used_backends());
}
kinds
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
pub struct FlagAddConfig {
pub backend: Option<BackendKind>,
}
impl FlagAddConfig {
pub fn get_used_backends(&self) -> HashSet<&BackendKind> {
let mut kinds = HashSet::default();
if let Some(kind) = &self.backend {
kinds.insert(kind);
}
kinds
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
pub struct FlagSetConfig {
pub backend: Option<BackendKind>,
}
impl FlagSetConfig {
pub fn get_used_backends(&self) -> HashSet<&BackendKind> {
let mut kinds = HashSet::default();
if let Some(kind) = &self.backend {
kinds.insert(kind);
}
kinds
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
pub struct FlagRemoveConfig {
pub backend: Option<BackendKind>,
}
impl FlagRemoveConfig {
pub fn get_used_backends(&self) -> HashSet<&BackendKind> {
let mut kinds = HashSet::default();
if let Some(kind) = &self.backend {
kinds.insert(kind);
}
kinds
}
}

View file

@ -0,0 +1,48 @@
pub mod arg;
pub mod command;
pub mod config;
use serde::Serialize;
use std::{collections::HashSet, ops};
/// Represents the flag variants.
#[derive(Clone, Debug, Eq, Hash, PartialEq, Ord, PartialOrd, Serialize)]
pub enum Flag {
Seen,
Answered,
Flagged,
Deleted,
Draft,
Custom(String),
}
impl From<&email::flag::Flag> for Flag {
fn from(flag: &email::flag::Flag) -> Self {
use email::flag::Flag::*;
match flag {
Seen => Flag::Seen,
Answered => Flag::Answered,
Flagged => Flag::Flagged,
Deleted => Flag::Deleted,
Draft => Flag::Draft,
Custom(flag) => Flag::Custom(flag.clone()),
}
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
pub struct Flags(pub HashSet<Flag>);
impl ops::Deref for Flags {
type Target = HashSet<Flag>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl From<email::flag::Flags> for Flags {
fn from(flags: email::flag::Flags) -> Self {
Flags(flags.iter().map(Flag::from).collect())
}
}

View file

@ -1,14 +1,80 @@
pub mod arg;
pub mod command;
pub mod config;
pub mod flag;
use anyhow::Result;
use email::account::AccountConfig;
use email::account::config::AccountConfig;
use serde::Serialize;
use std::ops;
use crate::{
cache::IdMapper,
flag::{Flag, Flags},
printer::{PrintTable, PrintTableOpts, WriteColor},
ui::Table,
Envelope, IdMapper, Mailbox,
ui::{Cell, Row, Table},
};
#[derive(Clone, Debug, Default, Serialize)]
pub struct Mailbox {
pub name: Option<String>,
pub addr: String,
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct Envelope {
pub id: String,
pub flags: Flags,
pub subject: String,
pub from: Mailbox,
pub date: String,
}
impl Table for Envelope {
fn head() -> Row {
Row::new()
.cell(Cell::new("ID").bold().underline().white())
.cell(Cell::new("FLAGS").bold().underline().white())
.cell(Cell::new("SUBJECT").shrinkable().bold().underline().white())
.cell(Cell::new("FROM").bold().underline().white())
.cell(Cell::new("DATE").bold().underline().white())
}
fn row(&self) -> Row {
let id = self.id.to_string();
let unseen = !self.flags.contains(&Flag::Seen);
let flags = {
let mut flags = String::new();
flags.push_str(if !unseen { " " } else { "" });
flags.push_str(if self.flags.contains(&Flag::Answered) {
""
} else {
" "
});
flags.push_str(if self.flags.contains(&Flag::Flagged) {
""
} else {
" "
});
flags
};
let subject = &self.subject;
let sender = if let Some(name) = &self.from.name {
name
} else {
&self.from.addr
};
let date = &self.date;
Row::new()
.cell(Cell::new(id).bold_if(unseen).red())
.cell(Cell::new(flags).bold_if(unseen).white())
.cell(Cell::new(subject).shrinkable().bold_if(unseen).green())
.cell(Cell::new(sender).bold_if(unseen).blue())
.cell(Cell::new(date).bold_if(unseen).yellow())
}
}
/// Represents the list of envelopes.
#[derive(Clone, Debug, Default, Serialize)]
pub struct Envelopes(Vec<Envelope>);
@ -17,7 +83,7 @@ impl Envelopes {
pub fn from_backend(
config: &AccountConfig,
id_mapper: &IdMapper,
envelopes: email::email::Envelopes,
envelopes: email::envelope::Envelopes,
) -> Result<Envelopes> {
let envelopes = envelopes
.iter()
@ -59,17 +125,19 @@ impl PrintTable for Envelopes {
#[cfg(test)]
mod tests {
use chrono::DateTime;
use email::account::AccountConfig;
use email::account::config::AccountConfig;
use std::env;
use crate::{Envelopes, IdMapper};
use crate::cache::IdMapper;
use super::Envelopes;
#[test]
fn default_datetime_fmt() {
let config = AccountConfig::default();
let id_mapper = IdMapper::Dummy;
let envelopes = email::email::Envelopes::from_iter([email::email::Envelope {
let envelopes = email::envelope::Envelopes::from_iter([email::envelope::Envelope {
date: DateTime::parse_from_rfc3339("2023-06-15T09:42:00+04:00").unwrap(),
..Default::default()
}]);
@ -89,7 +157,7 @@ mod tests {
..AccountConfig::default()
};
let envelopes = email::email::Envelopes::from_iter([email::email::Envelope {
let envelopes = email::envelope::Envelopes::from_iter([email::envelope::Envelope {
date: DateTime::parse_from_rfc3339("2023-06-15T09:42:00+04:00").unwrap(),
..Default::default()
}]);
@ -112,7 +180,7 @@ mod tests {
..AccountConfig::default()
};
let envelopes = email::email::Envelopes::from_iter([email::email::Envelope {
let envelopes = email::envelope::Envelopes::from_iter([email::envelope::Envelope {
date: DateTime::parse_from_rfc3339("2023-06-15T09:42:00+04:00").unwrap(),
..Default::default()
}]);

View file

@ -0,0 +1,25 @@
use clap::Parser;
use std::ops::Deref;
/// The raw message body argument parser.
#[derive(Debug, Parser)]
pub struct MessageRawBodyArg {
/// Prefill the template with a custom body.
#[arg(trailing_var_arg = true)]
#[arg(name = "body_raw", value_name = "BODY")]
pub raw: Vec<String>,
}
impl MessageRawBodyArg {
pub fn raw(self) -> String {
self.raw.join(" ").replace("\r", "").replace("\n", "\r\n")
}
}
impl Deref for MessageRawBodyArg {
type Target = Vec<String>;
fn deref(&self) -> &Self::Target {
&self.raw
}
}

View file

@ -0,0 +1,20 @@
use clap::Parser;
/// The envelope id argument parser.
#[derive(Debug, Parser)]
pub struct HeaderRawArgs {
/// Prefill the template with custom headers.
///
/// A raw header should follow the pattern KEY:VAL.
#[arg(long = "header", short = 'H', required = false)]
#[arg(name = "header-raw", value_name = "KEY:VAL", value_parser = raw_header_parser)]
pub raw: Vec<(String, String)>,
}
pub fn raw_header_parser(raw_header: &str) -> Result<(String, String), String> {
if let Some((key, val)) = raw_header.split_once(":") {
Ok((key.trim().to_owned(), val.trim().to_owned()))
} else {
Err(format!("cannot parse raw header {raw_header:?}"))
}
}

View file

@ -0,0 +1,20 @@
use clap::Parser;
pub mod body;
pub mod header;
pub mod reply;
/// The raw message argument parser.
#[derive(Debug, Parser)]
pub struct MessageRawArg {
/// The raw message, including headers and body.
#[arg(trailing_var_arg = true)]
#[arg(name = "message_raw", value_name = "MESSAGE")]
pub raw: Vec<String>,
}
impl MessageRawArg {
pub fn raw(self) -> String {
self.raw.join(" ").replace("\r", "").replace("\n", "\r\n")
}
}

View file

@ -0,0 +1,12 @@
use clap::Parser;
/// The reply to all argument parser.
#[derive(Debug, Parser)]
pub struct MessageReplyAllArg {
/// Reply to all recipients.
///
/// This argument will add all recipients for the To and Cc
/// headers.
#[arg(long, short = 'A')]
pub all: bool,
}

View file

@ -0,0 +1,89 @@
use anyhow::{Context, Result};
use clap::Parser;
use log::info;
use std::{fs, path::PathBuf};
use uuid::Uuid;
use crate::{
account::arg::name::AccountNameFlag, backend::Backend, cache::arg::disable::CacheDisableFlag,
config::TomlConfig, envelope::arg::ids::EnvelopeIdsArgs,
folder::arg::name::FolderNameOptionalFlag, printer::Printer,
};
/// Download all attachments for the given message.
///
/// This command allows you to download all attachments found for the
/// given message to your downloads directory.
#[derive(Debug, Parser)]
pub struct AttachmentDownloadCommand {
#[command(flatten)]
pub folder: FolderNameOptionalFlag,
#[command(flatten)]
pub envelopes: EnvelopeIdsArgs,
#[command(flatten)]
pub cache: CacheDisableFlag,
#[command(flatten)]
pub account: AccountNameFlag,
}
impl AttachmentDownloadCommand {
pub async fn execute(self, printer: &mut impl Printer, config: &TomlConfig) -> Result<()> {
info!("executing attachment download command");
let folder = &self.folder.name;
let account = self.account.name.as_ref().map(String::as_str);
let cache = self.cache.disable;
let (toml_account_config, account_config) =
config.clone().into_account_configs(account, cache)?;
let backend = Backend::new(toml_account_config, account_config.clone(), false).await?;
let ids = &self.envelopes.ids;
let emails = backend.get_messages(&folder, ids).await?;
let mut emails_count = 0;
let mut attachments_count = 0;
let mut ids = ids.iter();
for email in emails.to_vec() {
let id = ids.next().unwrap();
let attachments = email.attachments()?;
if attachments.is_empty() {
printer.print_log(format!("No attachment found for message {id}!"))?;
continue;
} else {
emails_count += 1;
}
printer.print_log(format!(
"{} attachment(s) found for message {id}!",
attachments.len()
))?;
for attachment in attachments {
let filename: PathBuf = attachment
.filename
.unwrap_or_else(|| Uuid::new_v4().to_string())
.into();
let filepath = account_config.get_download_file_path(&filename)?;
printer.print_log(format!("Downloading {:?}", filepath))?;
fs::write(&filepath, &attachment.body)
.with_context(|| format!("cannot save attachment at {filepath:?}"))?;
attachments_count += 1;
}
}
match attachments_count {
0 => printer.print("No attachment found!"),
1 => printer.print("Downloaded 1 attachment!"),
n => printer.print(format!(
"Downloaded {} attachment(s) from {} messages(s)!",
n, emails_count,
)),
}
}
}

View file

@ -0,0 +1,27 @@
pub mod download;
use anyhow::Result;
use clap::Subcommand;
use crate::{config::TomlConfig, printer::Printer};
use self::download::AttachmentDownloadCommand;
/// Manage attachments.
///
/// A message body can be composed of multiple MIME parts. An
/// attachment is the representation of a binary part of a message
/// body.
#[derive(Debug, Subcommand)]
pub enum AttachmentSubcommand {
#[command(arg_required_else_help = true)]
Download(AttachmentDownloadCommand),
}
impl AttachmentSubcommand {
pub async fn execute(self, printer: &mut impl Printer, config: &TomlConfig) -> Result<()> {
match self {
Self::Download(cmd) => cmd.execute(printer, config).await,
}
}
}

View file

@ -0,0 +1 @@
pub mod command;

View file

@ -0,0 +1,54 @@
use anyhow::Result;
use clap::Parser;
use log::info;
use crate::{
account::arg::name::AccountNameFlag,
backend::Backend,
cache::arg::disable::CacheDisableFlag,
config::TomlConfig,
envelope::arg::ids::EnvelopeIdsArgs,
folder::arg::name::{SourceFolderNameArg, TargetFolderNameArg},
printer::Printer,
};
/// Copy a message from a source folder to a target folder.
#[derive(Debug, Parser)]
pub struct MessageCopyCommand {
#[command(flatten)]
pub source_folder: SourceFolderNameArg,
#[command(flatten)]
pub target_folder: TargetFolderNameArg,
#[command(flatten)]
pub envelopes: EnvelopeIdsArgs,
#[command(flatten)]
pub cache: CacheDisableFlag,
#[command(flatten)]
pub account: AccountNameFlag,
}
impl MessageCopyCommand {
pub async fn execute(self, printer: &mut impl Printer, config: &TomlConfig) -> Result<()> {
info!("executing message copy command");
let from_folder = &self.source_folder.name;
let to_folder = &self.target_folder.name;
let account = self.account.name.as_ref().map(String::as_str);
let cache = self.cache.disable;
let (toml_account_config, account_config) =
config.clone().into_account_configs(account, cache)?;
let backend = Backend::new(toml_account_config, account_config.clone(), false).await?;
let ids = &self.envelopes.ids;
backend.copy_messages(from_folder, to_folder, ids).await?;
printer.print(format!(
"Message(s) successfully copied from {from_folder} to {to_folder}!"
))
}
}

View file

@ -0,0 +1,49 @@
use anyhow::Result;
use clap::Parser;
use log::info;
use crate::{
account::arg::name::AccountNameFlag, backend::Backend, cache::arg::disable::CacheDisableFlag,
config::TomlConfig, envelope::arg::ids::EnvelopeIdsArgs,
folder::arg::name::FolderNameOptionalFlag, printer::Printer,
};
/// Mark as deleted a message from a folder.
///
/// This command does not really delete the message: if the given
/// folder points to the trash folder, it adds the "deleted" flag to
/// its envelope, otherwise it moves it to the trash folder. Only the
/// expunge folder command truly deletes messages.
#[derive(Debug, Parser)]
pub struct MessageDeleteCommand {
#[command(flatten)]
pub folder: FolderNameOptionalFlag,
#[command(flatten)]
pub envelopes: EnvelopeIdsArgs,
#[command(flatten)]
pub cache: CacheDisableFlag,
#[command(flatten)]
pub account: AccountNameFlag,
}
impl MessageDeleteCommand {
pub async fn execute(self, printer: &mut impl Printer, config: &TomlConfig) -> Result<()> {
info!("executing message delete command");
let folder = &self.folder.name;
let account = self.account.name.as_ref().map(String::as_str);
let cache = self.cache.disable;
let (toml_account_config, account_config) =
config.clone().into_account_configs(account, cache)?;
let backend = Backend::new(toml_account_config, account_config.clone(), false).await?;
let ids = &self.envelopes.ids;
backend.delete_messages(folder, ids).await?;
printer.print("Message(s) successfully deleted from {from_folder} to {to_folder}!")
}
}

View file

@ -0,0 +1,69 @@
use anyhow::{anyhow, Result};
use clap::Parser;
use log::info;
use crate::{
account::arg::name::AccountNameFlag,
backend::Backend,
cache::arg::disable::CacheDisableFlag,
config::TomlConfig,
envelope::arg::ids::EnvelopeIdArg,
folder::arg::name::FolderNameOptionalFlag,
message::arg::{body::MessageRawBodyArg, header::HeaderRawArgs},
printer::Printer,
ui::editor,
};
/// Forward a message.
///
/// This command allows you to forward the given message using the
/// editor defined in your environment variable $EDITOR. When the
/// edition process finishes, you can choose between saving or sending
/// the final message.
#[derive(Debug, Parser)]
pub struct MessageForwardCommand {
#[command(flatten)]
pub folder: FolderNameOptionalFlag,
#[command(flatten)]
pub envelope: EnvelopeIdArg,
#[command(flatten)]
pub headers: HeaderRawArgs,
#[command(flatten)]
pub body: MessageRawBodyArg,
#[command(flatten)]
pub cache: CacheDisableFlag,
#[command(flatten)]
pub account: AccountNameFlag,
}
impl MessageForwardCommand {
pub async fn execute(self, printer: &mut impl Printer, config: &TomlConfig) -> Result<()> {
info!("executing message forward command");
let folder = &self.folder.name;
let account = self.account.name.as_ref().map(String::as_str);
let cache = self.cache.disable;
let (toml_account_config, account_config) =
config.clone().into_account_configs(account, cache)?;
let backend = Backend::new(toml_account_config, account_config.clone(), true).await?;
let id = self.envelope.id;
let tpl = backend
.get_messages(folder, &[id])
.await?
.first()
.ok_or(anyhow!("cannot find message"))?
.to_forward_tpl_builder(&account_config)
.with_headers(self.headers.raw)
.with_body(self.body.raw())
.build()
.await?;
editor::edit_tpl_with_editor(&account_config, printer, &backend, tpl).await
}
}

View file

@ -0,0 +1,81 @@
use anyhow::Result;
use clap::Parser;
use log::{debug, info};
use mail_builder::MessageBuilder;
use url::Url;
use crate::{
account::arg::name::AccountNameFlag, backend::Backend, cache::arg::disable::CacheDisableFlag,
config::TomlConfig, printer::Printer, ui::editor,
};
/// Parse and edit a message from a mailto URL string.
///
/// This command allows you to edit a message from the mailto format
/// using the editor defined in your environment variable
/// $EDITOR. When the edition process finishes, you can choose between
/// saving or sending the final message.
#[derive(Debug, Parser)]
pub struct MessageMailtoCommand {
/// The mailto url.
#[arg()]
pub url: Url,
#[command(flatten)]
pub cache: CacheDisableFlag,
#[command(flatten)]
pub account: AccountNameFlag,
}
impl MessageMailtoCommand {
pub fn new(url: &str) -> Result<Self> {
Ok(Self {
url: Url::parse(url)?,
cache: Default::default(),
account: Default::default(),
})
}
pub async fn execute(self, printer: &mut impl Printer, config: &TomlConfig) -> Result<()> {
info!("executing message mailto command");
let account = self.account.name.as_ref().map(String::as_str);
let cache = self.cache.disable;
let (toml_account_config, account_config) =
config.clone().into_account_configs(account, cache)?;
let backend = Backend::new(toml_account_config, account_config.clone(), true).await?;
let mut builder = MessageBuilder::new().to(self.url.path());
let mut body = String::new();
for (key, val) in self.url.query_pairs() {
match key.to_lowercase().as_bytes() {
b"cc" => builder = builder.cc(val.to_string()),
b"bcc" => builder = builder.bcc(val.to_string()),
b"subject" => builder = builder.subject(val),
b"body" => body += &val,
_ => (),
}
}
match account_config.find_full_signature() {
Ok(Some(ref signature)) => builder = builder.text_body(body + "\n\n" + signature),
Ok(None) => builder = builder.text_body(body),
Err(err) => {
debug!("cannot add signature to mailto message, skipping it: {err}");
debug!("{err:?}");
}
}
let tpl = account_config
.generate_tpl_interpreter()
.with_show_only_headers(account_config.get_message_write_headers())
.build()
.from_msg_builder(builder)
.await?;
editor::edit_tpl_with_editor(&account_config, printer, &backend, tpl).await
}
}

View file

@ -0,0 +1,81 @@
pub mod copy;
pub mod delete;
pub mod forward;
pub mod mailto;
pub mod move_;
pub mod read;
pub mod reply;
pub mod save;
pub mod send;
pub mod write;
use anyhow::Result;
use clap::Subcommand;
use crate::{config::TomlConfig, printer::Printer};
use self::{
copy::MessageCopyCommand, delete::MessageDeleteCommand, forward::MessageForwardCommand,
mailto::MessageMailtoCommand, move_::MessageMoveCommand, read::MessageReadCommand,
reply::MessageReplyCommand, save::MessageSaveCommand, send::MessageSendCommand,
write::MessageWriteCommand,
};
/// Manage messages.
///
/// A message is the content of an email. It is composed of headers
/// (located at the top of the message) and a body (located at the
/// bottom of the message). Both are separated by two new lines. This
/// subcommand allows you to manage them.
#[derive(Debug, Subcommand)]
pub enum MessageSubcommand {
#[command(arg_required_else_help = true)]
Read(MessageReadCommand),
#[command(aliases = ["add", "create", "new", "compose"])]
Write(MessageWriteCommand),
#[command()]
Reply(MessageReplyCommand),
#[command(aliases = ["fwd", "fd"])]
Forward(MessageForwardCommand),
#[command()]
Mailto(MessageMailtoCommand),
#[command(arg_required_else_help = true)]
Save(MessageSaveCommand),
#[command(arg_required_else_help = true)]
Send(MessageSendCommand),
#[command(arg_required_else_help = true)]
#[command(aliases = ["cpy", "cp"])]
Copy(MessageCopyCommand),
#[command(arg_required_else_help = true)]
#[command(alias = "mv")]
Move(MessageMoveCommand),
#[command(arg_required_else_help = true)]
#[command(aliases = ["remove", "rm"])]
Delete(MessageDeleteCommand),
}
impl MessageSubcommand {
pub async fn execute(self, printer: &mut impl Printer, config: &TomlConfig) -> Result<()> {
match self {
Self::Read(cmd) => cmd.execute(printer, config).await,
Self::Write(cmd) => cmd.execute(printer, config).await,
Self::Reply(cmd) => cmd.execute(printer, config).await,
Self::Forward(cmd) => cmd.execute(printer, config).await,
Self::Mailto(cmd) => cmd.execute(printer, config).await,
Self::Save(cmd) => cmd.execute(printer, config).await,
Self::Send(cmd) => cmd.execute(printer, config).await,
Self::Copy(cmd) => cmd.execute(printer, config).await,
Self::Move(cmd) => cmd.execute(printer, config).await,
Self::Delete(cmd) => cmd.execute(printer, config).await,
}
}
}

View file

@ -0,0 +1,54 @@
use anyhow::Result;
use clap::Parser;
use log::info;
use crate::{
account::arg::name::AccountNameFlag,
backend::Backend,
cache::arg::disable::CacheDisableFlag,
config::TomlConfig,
envelope::arg::ids::EnvelopeIdsArgs,
folder::arg::name::{SourceFolderNameArg, TargetFolderNameArg},
printer::Printer,
};
/// Move a message from a source folder to a target folder.
#[derive(Debug, Parser)]
pub struct MessageMoveCommand {
#[command(flatten)]
pub source_folder: SourceFolderNameArg,
#[command(flatten)]
pub target_folder: TargetFolderNameArg,
#[command(flatten)]
pub envelopes: EnvelopeIdsArgs,
#[command(flatten)]
pub cache: CacheDisableFlag,
#[command(flatten)]
pub account: AccountNameFlag,
}
impl MessageMoveCommand {
pub async fn execute(self, printer: &mut impl Printer, config: &TomlConfig) -> Result<()> {
info!("executing message move command");
let from_folder = &self.source_folder.name;
let to_folder = &self.target_folder.name;
let account = self.account.name.as_ref().map(String::as_str);
let cache = self.cache.disable;
let (toml_account_config, account_config) =
config.clone().into_account_configs(account, cache)?;
let backend = Backend::new(toml_account_config, account_config.clone(), false).await?;
let ids = &self.envelopes.ids;
backend.move_messages(from_folder, to_folder, ids).await?;
printer.print(format!(
"Message(s) successfully moved from {from_folder} to {to_folder}!"
))
}
}

View file

@ -0,0 +1,130 @@
use anyhow::Result;
use clap::Parser;
use log::info;
use mml::message::FilterParts;
use crate::{
account::arg::name::AccountNameFlag, backend::Backend, cache::arg::disable::CacheDisableFlag,
config::TomlConfig, envelope::arg::ids::EnvelopeIdsArgs,
folder::arg::name::FolderNameOptionalFlag, printer::Printer,
};
/// Read a message.
///
/// This command allows you to read a message. When reading a message,
/// the "seen" flag is automatically applied to the corresponding
/// envelope. To prevent this behaviour, use the --preview flag.
#[derive(Debug, Parser)]
pub struct MessageReadCommand {
#[command(flatten)]
pub folder: FolderNameOptionalFlag,
#[command(flatten)]
pub envelopes: EnvelopeIdsArgs,
/// Read the message without applying the "seen" flag to its
/// corresponding envelope.
#[arg(long, short)]
pub preview: bool,
/// Read the raw version of the given message.
///
/// The raw message represents the headers and the body as it is
/// on the backend, unedited: not decoded nor decrypted. This is
/// useful for debugging faulty messages, but also for
/// saving/sending/transfering messages.
#[arg(long, short)]
#[arg(conflicts_with = "no_headers")]
#[arg(conflicts_with = "headers")]
pub raw: bool,
/// Read only body of text/html parts.
///
/// This argument is useful when you need to read the HTML version
/// of a message. Combined with --no-headers, you can write it to
/// a .html file and open it with your favourite browser.
#[arg(long)]
#[arg(conflicts_with = "raw")]
pub html: bool,
/// Read only the body of the message.
///
/// All headers will be removed from the message.
#[arg(long)]
#[arg(conflicts_with = "raw")]
#[arg(conflicts_with = "headers")]
pub no_headers: bool,
/// List of headers that should be visible at the top of the
/// message.
///
/// If a given header is not found in the message, it will not be
/// visible. If no header is given, defaults to the one set up in
/// your TOML configuration file.
#[arg(long = "header", short = 'H', value_name = "NAME")]
#[arg(conflicts_with = "raw")]
#[arg(conflicts_with = "no_headers")]
pub headers: Vec<String>,
#[command(flatten)]
pub cache: CacheDisableFlag,
#[command(flatten)]
pub account: AccountNameFlag,
}
impl MessageReadCommand {
pub async fn execute(self, printer: &mut impl Printer, config: &TomlConfig) -> Result<()> {
info!("executing message read command");
let folder = &self.folder.name;
let account = self.account.name.as_ref().map(String::as_str);
let cache = self.cache.disable;
let (toml_account_config, account_config) =
config.clone().into_account_configs(account, cache)?;
let backend = Backend::new(toml_account_config, account_config.clone(), false).await?;
let ids = &self.envelopes.ids;
let emails = if self.preview {
backend.peek_messages(&folder, &ids).await
} else {
backend.get_messages(&folder, &ids).await
}?;
let mut glue = "";
let mut bodies = String::default();
for email in emails.to_vec() {
bodies.push_str(glue);
if self.raw {
// emails do not always have valid utf8, uses "lossy" to
// display what can be displayed
bodies.push_str(&String::from_utf8_lossy(email.raw()?).into_owned());
} else {
let tpl: String = email
.to_read_tpl(&account_config, |mut tpl| {
if self.no_headers {
tpl = tpl.with_hide_all_headers();
} else if !self.headers.is_empty() {
tpl = tpl.with_show_only_headers(&self.headers);
}
if self.html {
tpl = tpl.with_filter_parts(FilterParts::Only("text/html".into()));
}
tpl
})
.await?
.into();
bodies.push_str(&tpl);
}
glue = "\n\n";
}
printer.print(bodies)
}
}

View file

@ -0,0 +1,75 @@
use anyhow::{anyhow, Result};
use clap::Parser;
use email::flag::Flag;
use log::info;
use crate::{
account::arg::name::AccountNameFlag,
backend::Backend,
cache::arg::disable::CacheDisableFlag,
config::TomlConfig,
envelope::arg::ids::EnvelopeIdArg,
folder::arg::name::FolderNameOptionalFlag,
message::arg::{body::MessageRawBodyArg, header::HeaderRawArgs, reply::MessageReplyAllArg},
printer::Printer,
ui::editor,
};
/// Reply to a message.
///
/// This command allows you to reply to the given message using the
/// editor defined in your environment variable $EDITOR. When the
/// edition process finishes, you can choose between saving or sending
/// the final message.
#[derive(Debug, Parser)]
pub struct MessageReplyCommand {
#[command(flatten)]
pub folder: FolderNameOptionalFlag,
#[command(flatten)]
pub envelope: EnvelopeIdArg,
#[command(flatten)]
pub reply: MessageReplyAllArg,
#[command(flatten)]
pub headers: HeaderRawArgs,
#[command(flatten)]
pub body: MessageRawBodyArg,
#[command(flatten)]
pub cache: CacheDisableFlag,
#[command(flatten)]
pub account: AccountNameFlag,
}
impl MessageReplyCommand {
pub async fn execute(self, printer: &mut impl Printer, config: &TomlConfig) -> Result<()> {
info!("executing message reply command");
let folder = &self.folder.name;
let account = self.account.name.as_ref().map(String::as_str);
let cache = self.cache.disable;
let (toml_account_config, account_config) =
config.clone().into_account_configs(account, cache)?;
let backend = Backend::new(toml_account_config, account_config.clone(), true).await?;
let id = self.envelope.id;
let tpl = backend
.get_messages(folder, &[id])
.await?
.first()
.ok_or(anyhow!("cannot find message {id}"))?
.to_reply_tpl_builder(&account_config)
.with_headers(self.headers.raw)
.with_body(self.body.raw())
.with_reply_all(self.reply.all)
.build()
.await?;
editor::edit_tpl_with_editor(&account_config, printer, &backend, tpl).await?;
backend.add_flag(&folder, &[id], Flag::Answered).await
}
}

View file

@ -0,0 +1,59 @@
use anyhow::Result;
use clap::Parser;
use log::info;
use std::io::{self, BufRead, IsTerminal};
use crate::{
account::arg::name::AccountNameFlag, backend::Backend, cache::arg::disable::CacheDisableFlag,
config::TomlConfig, folder::arg::name::FolderNameOptionalFlag, message::arg::MessageRawArg,
printer::Printer,
};
/// Save a message to a folder.
///
/// This command allows you to add a raw message to the given folder.
#[derive(Debug, Parser)]
pub struct MessageSaveCommand {
#[command(flatten)]
pub folder: FolderNameOptionalFlag,
#[command(flatten)]
pub message: MessageRawArg,
#[command(flatten)]
pub cache: CacheDisableFlag,
#[command(flatten)]
pub account: AccountNameFlag,
}
impl MessageSaveCommand {
pub async fn execute(self, printer: &mut impl Printer, config: &TomlConfig) -> Result<()> {
info!("executing message save command");
let folder = &self.folder.name;
let account = self.account.name.as_ref().map(String::as_str);
let cache = self.cache.disable;
let (toml_account_config, account_config) =
config.clone().into_account_configs(account, cache)?;
let backend = Backend::new(toml_account_config, account_config.clone(), true).await?;
let is_tty = io::stdin().is_terminal();
let is_json = printer.is_json();
let msg = if is_tty || is_json {
self.message.raw()
} else {
io::stdin()
.lock()
.lines()
.filter_map(Result::ok)
.collect::<Vec<String>>()
.join("\r\n")
};
backend.add_raw_message(folder, msg.as_bytes()).await?;
printer.print(format!("Message successfully saved to {folder}!"))
}
}

View file

@ -0,0 +1,65 @@
use anyhow::Result;
use clap::Parser;
use email::flag::Flag;
use log::info;
use std::io::{self, BufRead, IsTerminal};
use crate::{
account::arg::name::AccountNameFlag, backend::Backend, cache::arg::disable::CacheDisableFlag,
config::TomlConfig, message::arg::MessageRawArg, printer::Printer,
};
/// Send a message.
///
/// This command allows you to send a raw message and to save a copy
/// to your send folder.
#[derive(Debug, Parser)]
pub struct MessageSendCommand {
#[command(flatten)]
pub message: MessageRawArg,
#[command(flatten)]
pub cache: CacheDisableFlag,
#[command(flatten)]
pub account: AccountNameFlag,
}
impl MessageSendCommand {
pub async fn execute(self, printer: &mut impl Printer, config: &TomlConfig) -> Result<()> {
info!("executing message send command");
let account = self.account.name.as_ref().map(String::as_str);
let cache = self.cache.disable;
let (toml_account_config, account_config) =
config.clone().into_account_configs(account, cache)?;
let backend = Backend::new(toml_account_config, account_config.clone(), true).await?;
let folder = account_config.get_sent_folder_alias()?;
let is_tty = io::stdin().is_terminal();
let is_json = printer.is_json();
let msg = if is_tty || is_json {
self.message.raw()
} else {
io::stdin()
.lock()
.lines()
.filter_map(Result::ok)
.collect::<Vec<String>>()
.join("\r\n")
};
backend.send_raw_message(msg.as_bytes()).await?;
if account_config.should_save_copy_sent_message() {
backend
.add_raw_message_with_flag(&folder, msg.as_bytes(), Flag::Seen)
.await?;
printer.print(format!("Message successfully sent and saved to {folder}!"))
} else {
printer.print("Message successfully sent!")
}
}
}

View file

@ -0,0 +1,56 @@
use anyhow::Result;
use clap::Parser;
use email::message::Message;
use log::info;
use crate::{
account::arg::name::AccountNameFlag,
backend::Backend,
cache::arg::disable::CacheDisableFlag,
config::TomlConfig,
message::arg::{body::MessageRawBodyArg, header::HeaderRawArgs},
printer::Printer,
ui::editor,
};
/// Write a new message.
///
/// This command allows you to write a new message using the editor
/// defined in your environment variable $EDITOR. When the edition
/// process finishes, you can choose between saving or sending the
/// final message.
#[derive(Debug, Parser)]
pub struct MessageWriteCommand {
#[command(flatten)]
pub headers: HeaderRawArgs,
#[command(flatten)]
pub body: MessageRawBodyArg,
#[command(flatten)]
pub cache: CacheDisableFlag,
#[command(flatten)]
pub account: AccountNameFlag,
}
impl MessageWriteCommand {
pub async fn execute(self, printer: &mut impl Printer, config: &TomlConfig) -> Result<()> {
info!("executing message write command");
let account = self.account.name.as_ref().map(String::as_str);
let cache = self.cache.disable;
let (toml_account_config, account_config) =
config.clone().into_account_configs(account, cache)?;
let backend = Backend::new(toml_account_config, account_config.clone(), true).await?;
let tpl = Message::new_tpl_builder(&account_config)
.with_headers(self.headers.raw)
.with_body(self.body.raw())
.build()
.await?;
editor::edit_tpl_with_editor(&account_config, printer, &backend, tpl).await
}
}

158
src/email/message/config.rs Normal file
View file

@ -0,0 +1,158 @@
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use crate::backend::BackendKind;
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
pub struct MessageConfig {
pub write: Option<MessageAddConfig>,
pub send: Option<MessageSendConfig>,
pub peek: Option<MessagePeekConfig>,
pub read: Option<MessageGetConfig>,
pub copy: Option<MessageCopyConfig>,
#[serde(rename = "move")]
pub move_: Option<MessageMoveConfig>,
}
impl MessageConfig {
pub fn get_used_backends(&self) -> HashSet<&BackendKind> {
let mut kinds = HashSet::default();
if let Some(add) = &self.write {
kinds.extend(add.get_used_backends());
}
if let Some(send) = &self.send {
kinds.extend(send.get_used_backends());
}
if let Some(peek) = &self.peek {
kinds.extend(peek.get_used_backends());
}
if let Some(get) = &self.read {
kinds.extend(get.get_used_backends());
}
if let Some(copy) = &self.copy {
kinds.extend(copy.get_used_backends());
}
if let Some(move_) = &self.move_ {
kinds.extend(move_.get_used_backends());
}
kinds
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
pub struct MessageAddConfig {
pub backend: Option<BackendKind>,
#[serde(flatten)]
pub remote: email::message::add_raw::config::MessageWriteConfig,
}
impl MessageAddConfig {
pub fn get_used_backends(&self) -> HashSet<&BackendKind> {
let mut kinds = HashSet::default();
if let Some(kind) = &self.backend {
kinds.insert(kind);
}
kinds
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
pub struct MessageSendConfig {
pub backend: Option<BackendKind>,
#[serde(flatten)]
pub remote: email::message::send_raw::config::MessageSendConfig,
}
impl MessageSendConfig {
pub fn get_used_backends(&self) -> HashSet<&BackendKind> {
let mut kinds = HashSet::default();
if let Some(kind) = &self.backend {
kinds.insert(kind);
}
kinds
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
pub struct MessagePeekConfig {
pub backend: Option<BackendKind>,
}
impl MessagePeekConfig {
pub fn get_used_backends(&self) -> HashSet<&BackendKind> {
let mut kinds = HashSet::default();
if let Some(kind) = &self.backend {
kinds.insert(kind);
}
kinds
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
pub struct MessageGetConfig {
pub backend: Option<BackendKind>,
#[serde(flatten)]
pub remote: email::message::get::config::MessageReadConfig,
}
impl MessageGetConfig {
pub fn get_used_backends(&self) -> HashSet<&BackendKind> {
let mut kinds = HashSet::default();
if let Some(kind) = &self.backend {
kinds.insert(kind);
}
kinds
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
pub struct MessageCopyConfig {
pub backend: Option<BackendKind>,
}
impl MessageCopyConfig {
pub fn get_used_backends(&self) -> HashSet<&BackendKind> {
let mut kinds = HashSet::default();
if let Some(kind) = &self.backend {
kinds.insert(kind);
}
kinds
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
pub struct MessageMoveConfig {
pub backend: Option<BackendKind>,
}
impl MessageMoveConfig {
pub fn get_used_backends(&self) -> HashSet<&BackendKind> {
let mut kinds = HashSet::default();
if let Some(kind) = &self.backend {
kinds.insert(kind);
}
kinds
}
}

5
src/email/message/mod.rs Normal file
View file

@ -0,0 +1,5 @@
pub mod arg;
pub mod attachment;
pub mod command;
pub mod config;
pub mod template;

View file

@ -0,0 +1,25 @@
use clap::Parser;
use std::ops::Deref;
/// The raw template body argument parser.
#[derive(Debug, Parser)]
pub struct TemplateRawBodyArg {
/// Prefill the template with a custom MML body.
#[arg(trailing_var_arg = true)]
#[arg(name = "body_raw", value_name = "BODY")]
pub raw: Vec<String>,
}
impl TemplateRawBodyArg {
pub fn raw(self) -> String {
self.raw.join(" ").replace("\r", "")
}
}
impl Deref for TemplateRawBodyArg {
type Target = Vec<String>;
fn deref(&self) -> &Self::Target {
&self.raw
}
}

View file

@ -0,0 +1,18 @@
pub mod body;
use clap::Parser;
/// The raw template argument parser.
#[derive(Debug, Parser)]
pub struct TemplateRawArg {
/// The raw template, including headers and MML body.
#[arg(trailing_var_arg = true)]
#[arg(name = "template_raw", value_name = "TEMPLATE")]
pub raw: Vec<String>,
}
impl TemplateRawArg {
pub fn raw(self) -> String {
self.raw.join(" ").replace("\r", "")
}
}

View file

@ -0,0 +1,69 @@
use anyhow::{anyhow, Result};
use clap::Parser;
use log::info;
use crate::{
account::arg::name::AccountNameFlag,
backend::Backend,
cache::arg::disable::CacheDisableFlag,
config::TomlConfig,
envelope::arg::ids::EnvelopeIdArg,
folder::arg::name::FolderNameOptionalFlag,
message::arg::{body::MessageRawBodyArg, header::HeaderRawArgs},
printer::Printer,
};
/// Generate a template for forwarding a message.
///
/// The generated template is prefilled with your email in a From
/// header as well as your signature. The forwarded message is also
/// prefilled in the body of the template, prefixed by a separator.
#[derive(Debug, Parser)]
pub struct TemplateForwardCommand {
#[command(flatten)]
pub folder: FolderNameOptionalFlag,
#[command(flatten)]
pub envelope: EnvelopeIdArg,
#[command(flatten)]
pub headers: HeaderRawArgs,
#[command(flatten)]
pub body: MessageRawBodyArg,
#[command(flatten)]
pub cache: CacheDisableFlag,
#[command(flatten)]
pub account: AccountNameFlag,
}
impl TemplateForwardCommand {
pub async fn execute(self, printer: &mut impl Printer, config: &TomlConfig) -> Result<()> {
info!("executing template forward command");
let folder = &self.folder.name;
let account = self.account.name.as_ref().map(String::as_str);
let cache = self.cache.disable;
let (toml_account_config, account_config) =
config.clone().into_account_configs(account, cache)?;
let backend = Backend::new(toml_account_config, account_config.clone(), false).await?;
let id = self.envelope.id;
let tpl: String = backend
.get_messages(folder, &[id])
.await?
.first()
.ok_or(anyhow!("cannot find message {id}"))?
.to_forward_tpl_builder(&account_config)
.with_headers(self.headers.raw)
.with_body(self.body.raw())
.build()
.await?
.into();
printer.print(tpl)
}
}

Some files were not shown because too many files have changed in this diff Show more