From 6e9e7cd30e845d4df2bcd6d91b1bc9083e43a4ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20DOUIN?= Date: Thu, 11 Mar 2021 17:05:01 +0100 Subject: [PATCH] add idle mode --- CHANGELOG.md | 5 +++ README.md | 119 +++++++++++++++++++++++--------------------------- src/config.rs | 37 ++++++++++++---- src/imap.rs | 62 +++++++++++++++++++++----- src/main.rs | 13 ++++++ 5 files changed, 152 insertions(+), 84 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 58a7f90..dbea442 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- IDLE support [#29] + ## [0.2.0] - 2021-03-10 ### Added @@ -66,4 +70,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [#21]: https://github.com/soywod/himalaya/issues/21 [#22]: https://github.com/soywod/himalaya/issues/22 [#25]: https://github.com/soywod/himalaya/issues/25 +[#29]: https://github.com/soywod/himalaya/issues/29 [#32]: https://github.com/soywod/himalaya/issues/32 diff --git a/README.md b/README.md index c2820c2..b30e89a 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ Minimalist CLI email client, written in Rust. * [Write a new message](#write-a-new-message) * [Reply to a message](#reply-to-a-message) * [Forward a message](#forward-a-message) + * [Listen to new messages](#listen-to-new-messages) * [License](https://github.com/soywod/himalaya/blob/master/LICENSE) * [Changelog](https://github.com/soywod/himalaya/blob/master/CHANGELOG.md) * [Credits](#credits) @@ -39,8 +40,8 @@ more flexibility. curl -sSL https://raw.githubusercontent.com/soywod/himalaya/master/install.sh | bash ``` -*See [wiki section](https://github.com/soywod/himalaya/wiki/Installation) for -more information.* +*See the [wiki section](https://github.com/soywod/himalaya/wiki/Installation) +for other installation methods.* ## Configuration @@ -50,8 +51,6 @@ more information.* name = "Your full name" downloads-dir = "/abs/path/to/downloads" -# Himalaya supports the multi-account -# Each account should be inside a TOML section [gmail] default = true email = "my.email@gmail.com" @@ -59,34 +58,16 @@ email = "my.email@gmail.com" imap-host = "imap.gmail.com" imap-port = 993 imap-login = "test@gmail.com" -imap-passwd_cmd = "pass show gmail" +imap-passwd-cmd = "pass show gmail" smtp-host = "smtp.gmail.com" smtp-port = 487 smtp-login = "test@gmail.com" -smtp-passwd_cmd = "pass show gmail" - -[posteo] -name = "Your overriden full name" -downloads-dir = "/abs/path/to/overriden/downloads" -email = "test@posteo.net" - -imap-host = "posteo.de" -imap-port = 993 -imap-login = "test@posteo.net" -imap-passwd_cmd = "security find-internet-password -gs posteo -w" - -smtp-host = "posteo.de" -smtp-port = 487 -smtp-login = "test@posteo.net" -smtp-passwd_cmd = "security find-internet-password -gs posteo -w" - -# [other accounts] -# ... +smtp-passwd-cmd = "security find-internet-password -gs posteo -w" ``` -*See [wiki section](https://github.com/soywod/himalaya/wiki/Configuration) for -more information.* +*See the [wiki section](https://github.com/soywod/himalaya/wiki/Configuration) +for all the options.* ## Usage @@ -104,12 +85,13 @@ FLAGS: OPTIONS: -a, --account Name of the account to use - -o, --output Format of the output to print [default: text] [possible values: text, json] + -o, --output Format of the output to print [possible values: text, json] SUBCOMMANDS: attachments Downloads all attachments from an email forward Forwards an email help Prints this message or the help of the given subcommand(s) + idle Starts the idle mode list Lists emails sorted by arrival date mailboxes Lists all available mailboxes read Reads text bodies of an email @@ -121,89 +103,98 @@ SUBCOMMANDS: write Writes a new email ``` -*See [wiki section](https://github.com/soywod/himalaya/wiki/Usage) for more -information.* +*See the [wiki section](https://github.com/soywod/himalaya/wiki/Usage) for more +information about commands.* ### List mailboxes -Shows mailboxes in a basic table. - ![image](https://user-images.githubusercontent.com/10437171/104848169-0e432000-58e4-11eb-8410-05f0404c0d99.png) -*See [wiki section](https://github.com/soywod/himalaya/wiki/Usage:list-mailboxes) -for more information.* +Shows mailboxes in a basic table. ### List messages -Shows messages in a basic table. - ![image](https://user-images.githubusercontent.com/10437171/104848096-aee51000-58e3-11eb-8d99-bcfab5ca28ba.png) -*See [wiki section](https://github.com/soywod/himalaya/wiki/Usage:list-messages) for -more information.* +Shows messages in a basic table. ### Search messages +![image](https://user-images.githubusercontent.com/10437171/110698977-9d86f880-81ee-11eb-8990-0ca89c7d4640.png) + Shows filtered messages in a basic table. The query should follow the [RFC-3501](https://tools.ietf.org/html/rfc3501#section-6.4.4). -![image](https://user-images.githubusercontent.com/10437171/110698977-9d86f880-81ee-11eb-8990-0ca89c7d4640.png) - -*See [wiki section](https://github.com/soywod/himalaya/wiki/Usage:search-messages) for -more information.* - ### Download attachments -Downloads all attachments directly to the [`downloads-dir`](#configuration). - ![image](https://user-images.githubusercontent.com/10437171/104848278-890c3b00-58e4-11eb-9b5c-48807c04f762.png) -*See [wiki section](https://github.com/soywod/himalaya/wiki/Usage:download-attachments) -for more information.* +Downloads all attachments from a message directly to the +[`downloads-dir`](https://github.com/soywod/himalaya/wiki/Configuration). ### Read a message -Shows the text content of a message (`text/plain` if exists, otherwise -`text/html`). Can be overriden by the `--mime-type` option. - ![image](https://user-images.githubusercontent.com/10437171/110701369-5d754500-81f1-11eb-932f-94c2ca8db068.png) -*See [wiki section](https://github.com/soywod/himalaya/wiki/Usage:read-a-message) for -more information.* +Shows the text content of a message (`text/plain` if exists, otherwise +`text/html`). ### Write a new message -Opens your default editor (from the `$EDITOR` environment variable) to compose -a new message. - ```bash himalaya write ``` -*See [wiki section](https://github.com/soywod/himalaya/wiki/Usage:write-a-new-message) for -more information.* +Opens your default editor (from the `$EDITOR` environment variable) to compose +a new message. ### Reply to a message -Opens your default editor to reply to a message. - ```bash himalaya reply --all 5123 ``` -*See [wiki section](https://github.com/soywod/himalaya/wiki/Usage:reply-to-a-message) for -more information.* +Opens your default editor to reply to a message. ### Forward a message -Opens your default editor to forward a message. - ```bash himalaya forward 5123 ``` -*See [wiki section](https://github.com/soywod/himalaya/wiki/Usage:forward-a-message) for -more information.* +Opens your default editor to forward a message. + +### Listen to new messages + +```bash +himalaya idle +``` + +Starts a session in idle mode (blocking). When a new message arrives, it runs +the command `notification-cmd` defined in the [config +file](https://github.com/soywod/himalaya/wiki/Configuration). + +Here a use case with [`systemd`](https://en.wikipedia.org/wiki/Systemd): + +```ini +# ~/.config/systemd/user/himalaya.service + +[Unit] +Description=Himalaya new messages notifier + +[Service] +ExecStart=/usr/local/bin/himalaya idle +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target +``` + +```bash +systemctl --user enable himalaya.service +systemctl --user start himalaya.service +``` ## Credits diff --git a/src/config.rs b/src/config.rs index 0d1b786..b8fc336 100644 --- a/src/config.rs +++ b/src/config.rs @@ -24,21 +24,28 @@ pub enum Error { GetAccountNotFoundError(String), GetAccountDefaultNotFoundError, OutputError(output::Error), + + // new erorrs, + RunNotifyCmdError(output::Error), } impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!(f, "config: ")?; + use Error::*; match self { - Error::IoError(err) => err.fmt(f), - Error::ParseTomlError(err) => err.fmt(f), - Error::ParseTomlAccountsError => write!(f, "no account found"), - Error::GetEnvVarError(err) => err.fmt(f), - Error::GetPathNotFoundError => write!(f, "path not found"), - Error::GetAccountNotFoundError(account) => write!(f, "account {} not found", account), - Error::GetAccountDefaultNotFoundError => write!(f, "no default account found"), - Error::OutputError(err) => err.fmt(f), + IoError(err) => err.fmt(f), + ParseTomlError(err) => err.fmt(f), + ParseTomlAccountsError => write!(f, "no account found"), + GetEnvVarError(err) => err.fmt(f), + GetPathNotFoundError => write!(f, "path not found"), + GetAccountNotFoundError(account) => write!(f, "account {} not found", account), + GetAccountDefaultNotFoundError => write!(f, "no default account found"), + OutputError(err) => err.fmt(f), + RunNotifyCmdError(err) => { + write!(f, "run notification cmd: ")?; + err.fmt(f) + } } } } @@ -124,6 +131,7 @@ impl Account { pub struct Config { pub name: String, pub downloads_dir: Option, + pub notification_cmd: Option, #[serde(flatten)] pub accounts: HashMap, @@ -202,4 +210,15 @@ impl Config { let name = account.name.as_ref().unwrap_or(&self.name); format!("{} <{}>", name, account.email) } + + pub fn run_notify_cmd(&self, subject: &str, sender: &str) -> Result<()> { + let default_cmd = format!(r#"notify-send "📫 {}" "{}""#, sender, subject); + let cmd = self + .notification_cmd + .as_ref() + .map(|s| format!(r#"{} "{}" "{}""#, s, subject, sender)) + .unwrap_or(default_cmd); + run_cmd(&cmd).map_err(Error::RunNotifyCmdError)?; + Ok(()) + } } diff --git a/src/imap.rs b/src/imap.rs index 4f9e6a2..6f5b9f6 100644 --- a/src/imap.rs +++ b/src/imap.rs @@ -2,9 +2,11 @@ use imap; use native_tls::{self, TlsConnector, TlsStream}; use std::{fmt, net::TcpStream, result}; -use crate::config::{self, Account}; -use crate::mbox::{Mbox, Mboxes}; -use crate::msg::{Msg, Msgs}; +use crate::{ + config::{self, Account, Config}, + mbox::{Mbox, Mboxes}, + msg::{Msg, Msgs}, +}; // Error wrapper @@ -17,26 +19,33 @@ pub enum Error { ReadEmailEmptyPartError(String, String), ExtractAttachmentsEmptyError(String), ConfigError(config::Error), + + // new errors + IdleError(imap::Error), } impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!(f, "imap: ")?; + use Error::*; match self { - Error::CreateTlsConnectorError(err) => err.fmt(f), - Error::CreateImapSession(err) => err.fmt(f), - Error::ParseEmailError(err) => err.fmt(f), - Error::ConfigError(err) => err.fmt(f), - Error::ReadEmailNotFoundError(uid) => { + CreateTlsConnectorError(err) => err.fmt(f), + CreateImapSession(err) => err.fmt(f), + ParseEmailError(err) => err.fmt(f), + ConfigError(err) => err.fmt(f), + ReadEmailNotFoundError(uid) => { write!(f, "no email found for uid {}", uid) } - Error::ReadEmailEmptyPartError(uid, mime) => { + ReadEmailEmptyPartError(uid, mime) => { write!(f, "no {} content found for uid {}", mime, uid) } - Error::ExtractAttachmentsEmptyError(uid) => { + ExtractAttachmentsEmptyError(uid) => { write!(f, "no attachment found for uid {}", uid) } + IdleError(err) => { + write!(f, "IMAP idle mode: ")?; + err.fmt(f) + } } } } @@ -97,6 +106,37 @@ impl<'a> ImapConnector<'a> { } } + fn last_new_seq(&mut self) -> Result> { + Ok(self.sess.uid_search("NEW")?.into_iter().next()) + } + + pub fn idle(&mut self, config: &Config, mbox: &str) -> Result<()> { + let mut prev_seq = 0; + self.sess.examine(mbox)?; + + loop { + self.sess + .idle() + .and_then(|idle| idle.wait_keepalive()) + .map_err(Error::IdleError)?; + + if let Some(seq) = self.last_new_seq()? { + if prev_seq != seq { + if let Some(msg) = self + .sess + .uid_fetch(seq.to_string(), "(ENVELOPE)")? + .iter() + .next() + .map(Msg::from) + { + config.run_notify_cmd(&msg.subject, &msg.sender)?; + prev_seq = seq; + } + } + } + } + } + pub fn list_mboxes(&mut self) -> Result { let mboxes = self .sess diff --git a/src/main.rs b/src/main.rs index dc9eb80..5486ae0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -253,6 +253,11 @@ fn run() -> Result<()> { .arg(mailbox_arg()), ), ) + .subcommand( + SubCommand::with_name("idle") + .about("Starts the idle mode") + .arg(mailbox_arg()), + ) .get_matches(); let account_name = matches.value_of("account"); @@ -525,6 +530,14 @@ fn run() -> Result<()> { imap_conn.logout(); } + if let Some(matches) = matches.subcommand_matches("idle") { + let config = Config::new_from_file()?; + let account = config.find_account_by_name(account_name)?; + let mut imap_conn = ImapConnector::new(&account)?; + let mbox = matches.value_of("mailbox").unwrap(); + imap_conn.idle(&config, &mbox)?; + } + Ok(()) }