buzz-ed/src/main.rs

370 lines
12 KiB
Rust
Raw Normal View History

2019-02-13 18:41:10 +01:00
extern crate askama_escape;
2019-02-13 20:16:29 +01:00
extern crate chrono;
2017-03-02 07:20:17 +01:00
extern crate imap;
extern crate mailparse;
extern crate native_tls;
extern crate notify_rust;
2017-09-26 17:00:46 +02:00
extern crate rayon;
extern crate systray;
extern crate toml;
extern crate xdg;
2017-03-02 07:20:17 +01:00
2018-05-06 21:34:32 +02:00
use native_tls::{TlsConnector, TlsStream};
2017-03-03 04:27:23 +01:00
use rayon::prelude::*;
2017-03-02 07:20:17 +01:00
2019-02-13 20:16:29 +01:00
use std::borrow::Cow;
use std::collections::BTreeMap;
2018-05-06 21:34:32 +02:00
use std::fs::File;
2017-03-02 07:20:17 +01:00
use std::io::prelude::*;
2017-09-26 21:11:31 +02:00
use std::net::TcpStream;
2018-05-06 21:34:32 +02:00
use std::process::Command;
2017-03-02 07:20:17 +01:00
use std::sync::mpsc;
use std::thread;
2018-05-06 21:34:32 +02:00
use std::time::Duration;
2017-03-02 07:20:17 +01:00
2017-09-26 21:11:31 +02:00
#[derive(Clone)]
struct Account {
name: String,
server: (String, u16),
username: String,
2017-03-02 07:20:17 +01:00
password: String,
}
2017-09-26 21:11:31 +02:00
impl Account {
pub fn connect(&self) -> Result<Connection<TlsStream<TcpStream>>, imap::error::Error> {
2018-09-18 17:14:59 +02:00
let tls = TlsConnector::builder().build()?;
2018-11-23 05:51:08 +01:00
imap::connect((&*self.server.0, self.server.1), &self.server.0, &tls).and_then(|c| {
let mut c = try!(c
.login(self.username.trim(), self.password.trim())
.map_err(|(e, _)| e));
let cap = try!(c.capabilities());
if !cap.iter().any(|&c| c == "IDLE") {
return Err(imap::error::Error::Bad(cap.iter().cloned().collect()));
}
try!(c.select("INBOX"));
Ok(Connection {
account: self.clone(),
socket: c,
2018-09-18 17:14:59 +02:00
})
2018-11-23 05:51:08 +01:00
})
2017-09-26 21:11:31 +02:00
}
}
struct Connection<T: Read + Write> {
account: Account,
2018-11-23 05:51:08 +01:00
socket: imap::Session<T>,
2017-09-26 21:11:31 +02:00
}
2018-11-23 05:51:08 +01:00
impl<T: Read + Write + imap::extensions::idle::SetReadTimeout> Connection<T> {
2017-09-26 21:11:31 +02:00
pub fn handle(mut self, account: usize, mut tx: mpsc::Sender<(usize, usize)>) {
loop {
if let Err(_) = self.check(account, &mut tx) {
// the connection has failed for some reason
// try to log out (we probably can't)
self.socket.logout().is_err();
break;
}
}
// try to reconnect
let mut wait = 1;
for _ in 0..5 {
println!(
"connection to {} lost; trying to reconnect...",
self.account.name
);
match self.account.connect() {
Ok(c) => {
println!("{} connection reestablished", self.account.name);
return c.handle(account, tx);
}
Err(imap::error::Error::Io(_)) => {
thread::sleep(Duration::from_secs(wait));
}
Err(_) => break,
}
wait *= 2;
}
}
fn check(
&mut self,
account: usize,
tx: &mut mpsc::Sender<(usize, usize)>,
) -> Result<(), imap::error::Error> {
// Keep track of all the e-mails we have already notified about
let mut last_notified = 0;
let mut notification = None::<notify_rust::NotificationHandle>;
2017-09-26 21:11:31 +02:00
loop {
// check current state of inbox
2019-02-19 18:18:58 +01:00
let mut uids = self.socket.uid_search("UNSEEN 1:*")?;
let num_unseen = uids.len();
if uids.iter().all(|&uid| uid <= last_notified) {
// there are no messages we haven't already notified about
uids.clear();
2017-09-26 21:11:31 +02:00
}
2019-02-19 18:18:58 +01:00
last_notified = std::cmp::max(last_notified, uids.iter().cloned().max().unwrap_or(0));
2017-09-26 21:11:31 +02:00
2019-02-13 20:16:29 +01:00
let mut subjects = BTreeMap::new();
2017-09-26 21:11:31 +02:00
if !uids.is_empty() {
2019-02-19 18:18:58 +01:00
let uids: Vec<_> = uids.into_iter().map(|v: u32| format!("{}", v)).collect();
2018-09-18 17:14:31 +02:00
for msg in self
.socket
2018-05-06 21:34:32 +02:00
.uid_fetch(&uids.join(","), "RFC822.HEADER")?
.iter()
{
2018-11-23 05:51:08 +01:00
let msg = msg.header();
2018-05-06 21:34:32 +02:00
if msg.is_none() {
continue;
}
match mailparse::parse_headers(msg.unwrap()) {
2017-09-26 21:11:31 +02:00
Ok((headers, _)) => {
use mailparse::MailHeaderMap;
2019-02-13 20:16:29 +01:00
let subject = match headers.get_first_value("Subject") {
Ok(Some(subject)) => Cow::from(subject),
Ok(None) => Cow::from("<no subject>"),
Err(e) => {
println!("failed to get message subject: {:?}", e);
continue;
2017-09-26 21:11:31 +02:00
}
2019-02-13 20:16:29 +01:00
};
let date = match headers.get_first_value("Date") {
Ok(Some(date)) => {
match chrono::DateTime::parse_from_rfc2822(&date) {
Ok(date) => date.with_timezone(&chrono::Local),
Err(e) => {
println!("failed to parse message date: {:?}", e);
chrono::Local::now()
}
}
2017-09-26 21:11:31 +02:00
}
2019-02-13 20:16:29 +01:00
Ok(None) => chrono::Local::now(),
2017-09-26 21:11:31 +02:00
Err(e) => {
2019-02-13 20:16:29 +01:00
println!("failed to get message date: {:?}", e);
continue;
2017-09-26 21:11:31 +02:00
}
2019-02-13 20:16:29 +01:00
};
subjects.insert(date, subject);
2017-09-26 21:11:31 +02:00
}
Err(e) => println!("failed to parse headers of message: {:?}", e),
}
}
}
if !subjects.is_empty() {
use notify_rust::{Notification, NotificationHint};
let title = format!(
"@{} has new mail ({} unseen)",
2018-05-06 21:34:32 +02:00
self.account.name, num_unseen
2017-09-26 21:11:31 +02:00
);
2019-02-13 20:16:29 +01:00
// we want the n newest e-mail in reverse chronological order
let mut body = String::new();
2019-02-13 20:16:29 +01:00
for subject in subjects.values().rev() {
body.push_str("> ");
body.push_str(subject);
body.push_str("\n");
2019-02-13 20:16:29 +01:00
}
let body = body.trim_end();
2019-02-13 20:16:29 +01:00
2017-09-26 21:11:31 +02:00
println!("! {}", title);
println!("{}", body);
if let Some(mut n) = notification.take() {
n.summary(&title)
.body(&format!("{}", askama_escape::escape(body)));
n.update();
} else {
notification = Some(
Notification::new()
.summary(&title)
.body(&format!("{}", askama_escape::escape(body)))
.icon("notification-message-email")
.hint(NotificationHint::Category("email.arrived".to_owned()))
.id(42) // for some reason, just updating isn't enough for dunst
.show()
.expect("failed to launch notify-send"),
);
}
2017-09-26 21:11:31 +02:00
}
tx.send((account, num_unseen)).unwrap();
// IDLE until we see changes
2017-09-30 22:00:36 +02:00
self.socket.idle()?.wait_keepalive()?;
2017-09-26 21:11:31 +02:00
}
}
}
2017-03-02 07:20:17 +01:00
fn main() {
// Load the user's config
let xdg = match xdg::BaseDirectories::new() {
Ok(xdg) => xdg,
Err(e) => {
println!("Could not find configuration file buzz.toml: {}", e);
return;
}
};
let config = match xdg.find_config_file("buzz.toml") {
Some(config) => config,
None => {
println!("Could not find configuration file buzz.toml");
return;
}
};
2017-05-12 23:16:21 +02:00
let config = {
let mut f = match File::open(config) {
Ok(f) => f,
Err(e) => {
println!("Could not open configuration file buzz.toml: {}", e);
return;
}
};
let mut s = String::new();
if let Err(e) = f.read_to_string(&mut s) {
println!("Could not read configuration file buzz.toml: {}", e);
2017-03-02 07:20:17 +01:00
return;
}
2017-05-12 23:16:21 +02:00
match s.parse::<toml::Value>() {
Ok(t) => t,
Err(e) => {
println!("Could not parse configuration file buzz.toml: {}", e);
return;
}
2017-03-02 07:20:17 +01:00
}
};
// Figure out what accounts we have to deal with
2017-05-12 23:16:21 +02:00
let accounts: Vec<_> = match config.as_table() {
2018-09-18 17:14:31 +02:00
Some(t) => t
.iter()
2017-07-13 03:40:28 +02:00
.filter_map(|(name, v)| match v.as_table() {
None => {
println!("Configuration for account {} is broken: not a table", name);
None
}
Some(t) => {
let pwcmd = match t.get("pwcmd").and_then(|p| p.as_str()) {
None => return None,
Some(pwcmd) => pwcmd,
};
2017-05-12 23:12:03 +02:00
2017-07-13 03:40:28 +02:00
let password = match Command::new("sh").arg("-c").arg(pwcmd).output() {
Ok(output) => String::from_utf8_lossy(&output.stdout).into_owned(),
Err(e) => {
println!("Failed to launch password command for {}: {}", name, e);
return None;
}
};
2017-05-12 23:12:03 +02:00
2017-07-13 03:40:28 +02:00
Some(Account {
2017-09-26 21:11:31 +02:00
name: name.as_str().to_owned(),
2017-07-13 03:40:28 +02:00
server: (
2017-09-26 21:11:31 +02:00
t["server"].as_str().unwrap().to_owned(),
2017-07-13 03:40:28 +02:00
t["port"].as_integer().unwrap() as u16,
),
2017-09-26 21:11:31 +02:00
username: t["username"].as_str().unwrap().to_owned(),
2017-07-13 03:40:28 +02:00
password: password,
})
}
2018-10-05 20:54:26 +02:00
})
.collect(),
2017-03-02 07:20:17 +01:00
None => {
println!("Could not parse configuration file buzz.toml: not a table");
return;
}
};
if accounts.is_empty() {
println!("No accounts in config; exiting...");
return;
}
// Create a new application
let mut app = match systray::Application::new() {
Ok(app) => app,
Err(e) => {
println!("Could not create gtk application: {}", e);
return;
}
};
2018-05-06 21:34:32 +02:00
if let Err(e) =
app.set_icon_from_file(&"/usr/share/icons/Faenza/stock/24/stock_disconnect.png".to_string())
2017-09-26 17:00:46 +02:00
{
2017-03-02 07:20:17 +01:00
println!("Could not set application icon: {}", e);
}
2017-09-26 17:00:46 +02:00
if let Err(e) = app.add_menu_item(&"Quit".to_string(), |window| {
window.quit();
}) {
2017-03-02 07:20:17 +01:00
println!("Could not add application Quit menu option: {}", e);
}
// TODO: w.set_tooltip(&"Whatever".to_string());
// TODO: app.wait_for_message();
2017-05-04 03:32:44 +02:00
let accounts: Vec<_> = accounts
.par_iter()
2017-05-12 23:16:21 +02:00
.filter_map(|account| {
2017-03-03 04:27:23 +01:00
let mut wait = 1;
for _ in 0..5 {
2017-09-26 21:11:31 +02:00
match account.connect() {
2017-03-03 04:27:23 +01:00
Ok(c) => return Some(c),
Err(imap::error::Error::Io(e)) => {
2017-07-06 01:30:25 +02:00
println!(
"Failed to connect account {}: {}; retrying in {}s",
2018-05-06 21:34:32 +02:00
account.name, e, wait
2017-07-06 01:30:25 +02:00
);
2017-03-03 04:27:23 +01:00
thread::sleep(Duration::from_secs(wait));
}
Err(e) => {
2018-05-06 21:34:32 +02:00
println!("{} host produced bad IMAP tunnel: {:?}", account.name, e);
2017-03-03 04:27:23 +01:00
break;
}
}
wait *= 2;
}
None
2018-10-05 20:54:26 +02:00
})
.collect();
2017-03-02 07:20:17 +01:00
2017-03-03 04:27:23 +01:00
if accounts.is_empty() {
println!("No accounts in config worked; exiting...");
return;
}
2017-03-02 07:20:17 +01:00
// We have now connected
2018-05-06 21:34:32 +02:00
app.set_icon_from_file(&"/usr/share/icons/Faenza/stock/24/stock_connect.png".to_string())
2017-05-04 03:32:44 +02:00
.ok();
2017-03-02 07:20:17 +01:00
let (tx, rx) = mpsc::channel();
let mut unseen: Vec<_> = accounts.iter().map(|_| 0).collect();
2017-09-26 21:11:31 +02:00
for (i, conn) in accounts.into_iter().enumerate() {
2017-03-02 07:20:17 +01:00
let tx = tx.clone();
thread::spawn(move || {
2017-09-26 21:11:31 +02:00
conn.handle(i, tx);
2017-03-02 07:20:17 +01:00
});
}
for (i, num_unseen) in rx {
unseen[i] = num_unseen;
if unseen.iter().sum::<usize>() == 0 {
2018-09-18 17:14:31 +02:00
app.set_icon_from_file(
&"/usr/share/icons/oxygen/base/32x32/status/mail-unread.png".to_string(),
2018-10-05 20:54:26 +02:00
)
.unwrap();
2017-03-02 07:20:17 +01:00
} else {
2017-07-06 01:30:25 +02:00
app.set_icon_from_file(
&"/usr/share/icons/oxygen/base/32x32/status/mail-unread-new.png".to_string(),
2018-10-05 20:54:26 +02:00
)
.unwrap();
2017-03-02 07:20:17 +01:00
}
}
}