add idle mode

This commit is contained in:
Clément DOUIN 2021-03-11 17:05:01 +01:00
parent b2f1543bbf
commit 6e9e7cd30e
No known key found for this signature in database
GPG key ID: 69C9B9CFFDEE2DEF
5 changed files with 152 additions and 84 deletions

View file

@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Added
- IDLE support [#29]
## [0.2.0] - 2021-03-10 ## [0.2.0] - 2021-03-10
### Added ### 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 [#21]: https://github.com/soywod/himalaya/issues/21
[#22]: https://github.com/soywod/himalaya/issues/22 [#22]: https://github.com/soywod/himalaya/issues/22
[#25]: https://github.com/soywod/himalaya/issues/25 [#25]: https://github.com/soywod/himalaya/issues/25
[#29]: https://github.com/soywod/himalaya/issues/29
[#32]: https://github.com/soywod/himalaya/issues/32 [#32]: https://github.com/soywod/himalaya/issues/32

119
README.md
View file

@ -18,6 +18,7 @@ Minimalist CLI email client, written in Rust.
* [Write a new message](#write-a-new-message) * [Write a new message](#write-a-new-message)
* [Reply to a message](#reply-to-a-message) * [Reply to a message](#reply-to-a-message)
* [Forward a message](#forward-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) * [License](https://github.com/soywod/himalaya/blob/master/LICENSE)
* [Changelog](https://github.com/soywod/himalaya/blob/master/CHANGELOG.md) * [Changelog](https://github.com/soywod/himalaya/blob/master/CHANGELOG.md)
* [Credits](#credits) * [Credits](#credits)
@ -39,8 +40,8 @@ more flexibility.
curl -sSL https://raw.githubusercontent.com/soywod/himalaya/master/install.sh | bash curl -sSL https://raw.githubusercontent.com/soywod/himalaya/master/install.sh | bash
``` ```
*See [wiki section](https://github.com/soywod/himalaya/wiki/Installation) for *See the [wiki section](https://github.com/soywod/himalaya/wiki/Installation)
more information.* for other installation methods.*
## Configuration ## Configuration
@ -50,8 +51,6 @@ more information.*
name = "Your full name" name = "Your full name"
downloads-dir = "/abs/path/to/downloads" downloads-dir = "/abs/path/to/downloads"
# Himalaya supports the multi-account
# Each account should be inside a TOML section
[gmail] [gmail]
default = true default = true
email = "my.email@gmail.com" email = "my.email@gmail.com"
@ -59,34 +58,16 @@ email = "my.email@gmail.com"
imap-host = "imap.gmail.com" imap-host = "imap.gmail.com"
imap-port = 993 imap-port = 993
imap-login = "test@gmail.com" imap-login = "test@gmail.com"
imap-passwd_cmd = "pass show gmail" imap-passwd-cmd = "pass show gmail"
smtp-host = "smtp.gmail.com" smtp-host = "smtp.gmail.com"
smtp-port = 487 smtp-port = 487
smtp-login = "test@gmail.com" smtp-login = "test@gmail.com"
smtp-passwd_cmd = "pass show gmail" smtp-passwd-cmd = "security find-internet-password -gs posteo -w"
[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]
# ...
``` ```
*See [wiki section](https://github.com/soywod/himalaya/wiki/Configuration) for *See the [wiki section](https://github.com/soywod/himalaya/wiki/Configuration)
more information.* for all the options.*
## Usage ## Usage
@ -104,12 +85,13 @@ FLAGS:
OPTIONS: OPTIONS:
-a, --account <STRING> Name of the account to use -a, --account <STRING> Name of the account to use
-o, --output <STRING> Format of the output to print [default: text] [possible values: text, json] -o, --output <STRING> Format of the output to print [possible values: text, json]
SUBCOMMANDS: SUBCOMMANDS:
attachments Downloads all attachments from an email attachments Downloads all attachments from an email
forward Forwards an email forward Forwards an email
help Prints this message or the help of the given subcommand(s) help Prints this message or the help of the given subcommand(s)
idle Starts the idle mode
list Lists emails sorted by arrival date list Lists emails sorted by arrival date
mailboxes Lists all available mailboxes mailboxes Lists all available mailboxes
read Reads text bodies of an email read Reads text bodies of an email
@ -121,89 +103,98 @@ SUBCOMMANDS:
write Writes a new email write Writes a new email
``` ```
*See [wiki section](https://github.com/soywod/himalaya/wiki/Usage) for more *See the [wiki section](https://github.com/soywod/himalaya/wiki/Usage) for more
information.* information about commands.*
### List mailboxes ### List mailboxes
Shows mailboxes in a basic table.
![image](https://user-images.githubusercontent.com/10437171/104848169-0e432000-58e4-11eb-8410-05f0404c0d99.png) ![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) Shows mailboxes in a basic table.
for more information.*
### List messages ### List messages
Shows messages in a basic table.
![image](https://user-images.githubusercontent.com/10437171/104848096-aee51000-58e3-11eb-8d99-bcfab5ca28ba.png) ![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 Shows messages in a basic table.
more information.*
### Search messages ### 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 Shows filtered messages in a basic table. The query should follow the
[RFC-3501](https://tools.ietf.org/html/rfc3501#section-6.4.4). [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 ### 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) ![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) Downloads all attachments from a message directly to the
for more information.* [`downloads-dir`](https://github.com/soywod/himalaya/wiki/Configuration).
### Read a message ### 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) ![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 Shows the text content of a message (`text/plain` if exists, otherwise
more information.* `text/html`).
### Write a new message ### Write a new message
Opens your default editor (from the `$EDITOR` environment variable) to compose
a new message.
```bash ```bash
himalaya write himalaya write
``` ```
*See [wiki section](https://github.com/soywod/himalaya/wiki/Usage:write-a-new-message) for Opens your default editor (from the `$EDITOR` environment variable) to compose
more information.* a new message.
### Reply to a message ### Reply to a message
Opens your default editor to reply to a message.
```bash ```bash
himalaya reply --all 5123 himalaya reply --all 5123
``` ```
*See [wiki section](https://github.com/soywod/himalaya/wiki/Usage:reply-to-a-message) for Opens your default editor to reply to a message.
more information.*
### Forward a message ### Forward a message
Opens your default editor to forward a message.
```bash ```bash
himalaya forward 5123 himalaya forward 5123
``` ```
*See [wiki section](https://github.com/soywod/himalaya/wiki/Usage:forward-a-message) for Opens your default editor to forward a message.
more information.*
### 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 ## Credits

View file

@ -24,21 +24,28 @@ pub enum Error {
GetAccountNotFoundError(String), GetAccountNotFoundError(String),
GetAccountDefaultNotFoundError, GetAccountDefaultNotFoundError,
OutputError(output::Error), OutputError(output::Error),
// new erorrs,
RunNotifyCmdError(output::Error),
} }
impl fmt::Display for Error { impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "config: ")?; use Error::*;
match self { match self {
Error::IoError(err) => err.fmt(f), IoError(err) => err.fmt(f),
Error::ParseTomlError(err) => err.fmt(f), ParseTomlError(err) => err.fmt(f),
Error::ParseTomlAccountsError => write!(f, "no account found"), ParseTomlAccountsError => write!(f, "no account found"),
Error::GetEnvVarError(err) => err.fmt(f), GetEnvVarError(err) => err.fmt(f),
Error::GetPathNotFoundError => write!(f, "path not found"), GetPathNotFoundError => write!(f, "path not found"),
Error::GetAccountNotFoundError(account) => write!(f, "account {} not found", account), GetAccountNotFoundError(account) => write!(f, "account {} not found", account),
Error::GetAccountDefaultNotFoundError => write!(f, "no default account found"), GetAccountDefaultNotFoundError => write!(f, "no default account found"),
Error::OutputError(err) => err.fmt(f), 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 struct Config {
pub name: String, pub name: String,
pub downloads_dir: Option<PathBuf>, pub downloads_dir: Option<PathBuf>,
pub notification_cmd: Option<String>,
#[serde(flatten)] #[serde(flatten)]
pub accounts: HashMap<String, Account>, pub accounts: HashMap<String, Account>,
@ -202,4 +210,15 @@ impl Config {
let name = account.name.as_ref().unwrap_or(&self.name); let name = account.name.as_ref().unwrap_or(&self.name);
format!("{} <{}>", name, account.email) 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(())
}
} }

View file

@ -2,9 +2,11 @@ use imap;
use native_tls::{self, TlsConnector, TlsStream}; use native_tls::{self, TlsConnector, TlsStream};
use std::{fmt, net::TcpStream, result}; use std::{fmt, net::TcpStream, result};
use crate::config::{self, Account}; use crate::{
use crate::mbox::{Mbox, Mboxes}; config::{self, Account, Config},
use crate::msg::{Msg, Msgs}; mbox::{Mbox, Mboxes},
msg::{Msg, Msgs},
};
// Error wrapper // Error wrapper
@ -17,26 +19,33 @@ pub enum Error {
ReadEmailEmptyPartError(String, String), ReadEmailEmptyPartError(String, String),
ExtractAttachmentsEmptyError(String), ExtractAttachmentsEmptyError(String),
ConfigError(config::Error), ConfigError(config::Error),
// new errors
IdleError(imap::Error),
} }
impl fmt::Display for Error { impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "imap: ")?; use Error::*;
match self { match self {
Error::CreateTlsConnectorError(err) => err.fmt(f), CreateTlsConnectorError(err) => err.fmt(f),
Error::CreateImapSession(err) => err.fmt(f), CreateImapSession(err) => err.fmt(f),
Error::ParseEmailError(err) => err.fmt(f), ParseEmailError(err) => err.fmt(f),
Error::ConfigError(err) => err.fmt(f), ConfigError(err) => err.fmt(f),
Error::ReadEmailNotFoundError(uid) => { ReadEmailNotFoundError(uid) => {
write!(f, "no email found for uid {}", 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) write!(f, "no {} content found for uid {}", mime, uid)
} }
Error::ExtractAttachmentsEmptyError(uid) => { ExtractAttachmentsEmptyError(uid) => {
write!(f, "no attachment found for uid {}", 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<Option<u32>> {
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<Mboxes> { pub fn list_mboxes(&mut self) -> Result<Mboxes> {
let mboxes = self let mboxes = self
.sess .sess

View file

@ -253,6 +253,11 @@ fn run() -> Result<()> {
.arg(mailbox_arg()), .arg(mailbox_arg()),
), ),
) )
.subcommand(
SubCommand::with_name("idle")
.about("Starts the idle mode")
.arg(mailbox_arg()),
)
.get_matches(); .get_matches();
let account_name = matches.value_of("account"); let account_name = matches.value_of("account");
@ -525,6 +530,14 @@ fn run() -> Result<()> {
imap_conn.logout(); 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(()) Ok(())
} }