This commit is contained in:
Shautvast 2026-02-17 20:36:24 +01:00
parent 96e170f1c9
commit 1412ecfbb8
4 changed files with 140 additions and 139 deletions

View file

@ -1,6 +0,0 @@
[imap]
host = "imap.gmail.com"
port = 993
username =
password =
use_tls = true

51
src/connect.rs Normal file
View file

@ -0,0 +1,51 @@
use std::net::TcpStream;
use native_tls::TlsStream;
use crate::config::Config;
pub(crate) enum ImapSession {
Plain(imap::Session<TcpStream>),
Tls(imap::Session<TlsStream<TcpStream>>),
}
impl ImapSession {
pub(crate) fn noop(&mut self) -> imap::error::Result<()> {
match self {
Self::Plain(s) => s.noop(),
Self::Tls(s) => s.noop(),
}
}
pub(crate) fn logout(&mut self) -> imap::error::Result<()> {
match self {
Self::Plain(s) => s.logout(),
Self::Tls(s) => s.logout(),
}
}
}
pub(crate) fn connect(config: &Config) -> Result<ImapSession, String> {
let imap_cfg = &config.imap;
if imap_cfg.use_tls {
let tls = native_tls::TlsConnector::builder()
.build()
.map_err(|e| e.to_string())?;
let client = imap::connect(
(&*imap_cfg.host, imap_cfg.port),
&imap_cfg.host,
&tls,
)
.map_err(|e| e.to_string())?;
let session = client
.login(&imap_cfg.username, &imap_cfg.password)
.map_err(|(e, _)| e.to_string())?;
Ok(ImapSession::Tls(session))
} else {
let stream =
TcpStream::connect((&*imap_cfg.host, imap_cfg.port)).map_err(|e| e.to_string())?;
let client = imap::Client::new(stream);
let session = client
.login(&imap_cfg.username, &imap_cfg.password)
.map_err(|(e, _)| e.to_string())?;
Ok(ImapSession::Plain(session))
}
}

83
src/inbox.rs Normal file
View file

@ -0,0 +1,83 @@
use crate::config::Config;
use crate::connect::ImapSession;
use crate::{connect, Email};
const MAX_FETCH: u32 = 50;
/// Refresh inbox using NOOP + fetch. Reconnects on error.
pub(crate) fn refresh(
session: &mut Option<ImapSession>,
config: &Config,
) -> Result<Vec<Email>, String> {
// If we have a session, try NOOP to keep alive / detect changes
if let Some(s) = session.as_mut() {
if s.noop().is_ok() {
return fetch_inbox(s);
}
}
// Session is dead or missing — reconnect
*session = None;
let mut new_session = connect::connect(config)?;
let result = fetch_inbox(&mut new_session);
*session = Some(new_session);
result
}
fn fetch_range(exists: u32) -> String {
let start = exists.saturating_sub(MAX_FETCH - 1).max(1);
format!("{}:{}", start, exists)
}
fn fetch_inbox(session: &mut ImapSession) -> Result<Vec<Email>, String> {
match session {
ImapSession::Plain(s) => {
let mailbox = s.select("INBOX").map_err(|e| e.to_string())?;
if mailbox.exists == 0 {
return Ok(Vec::new());
}
let range = fetch_range(mailbox.exists);
let messages = s
.fetch(range, "BODY.PEEK[HEADER.FIELDS (SUBJECT FROM DATE)]")
.map_err(|e| e.to_string())?;
Ok(parse_emails(&messages))
}
ImapSession::Tls(s) => {
let mailbox = s.select("INBOX").map_err(|e| e.to_string())?;
if mailbox.exists == 0 {
return Ok(Vec::new());
}
let range = fetch_range(mailbox.exists);
let messages = s
.fetch(range, "BODY.PEEK[HEADER.FIELDS (SUBJECT FROM DATE)]")
.map_err(|e| e.to_string())?;
Ok(parse_emails(&messages))
}
}
}
fn parse_emails(fetches: &[imap::types::Fetch]) -> Vec<Email> {
let mut emails = Vec::new();
for message in fetches {
if let Some(body) = message.header() {
let header = String::from_utf8_lossy(body);
let mut subject = String::new();
let mut from = String::new();
let mut date = String::new();
for line in header.lines() {
if let Some(val) = line.strip_prefix("Subject: ") {
subject = val.to_string();
} else if let Some(val) = line.strip_prefix("From: ") {
from = val.to_string();
} else if let Some(val) = line.strip_prefix("Date: ") {
date = val.to_string();
}
}
emails.push(Email { subject, from, date });
}
}
emails
}

View file

@ -1,9 +1,7 @@
use std::io::{Error, Stdout}; use std::io::{Error, Stdout};
use std::net::TcpStream;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use crossterm::event; use crossterm::event;
use crossterm::event::{Event, KeyCode}; use crossterm::event::{Event, KeyCode};
use native_tls::TlsStream;
use ratatui::backend::CrosstermBackend; use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Direction, Layout}; use ratatui::layout::{Constraint, Direction, Layout};
use ratatui::prelude::{Color, Modifier, Style}; use ratatui::prelude::{Color, Modifier, Style};
@ -12,6 +10,8 @@ use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph};
use crate::config::Config; use crate::config::Config;
pub mod config; pub mod config;
mod connect;
mod inbox;
const POLL_INTERVAL: Duration = Duration::from_secs(30); const POLL_INTERVAL: Duration = Duration::from_secs(30);
@ -21,136 +21,9 @@ struct Email {
date: String, date: String,
} }
enum ImapSession {
Plain(imap::Session<TcpStream>),
Tls(imap::Session<TlsStream<TcpStream>>),
}
impl ImapSession {
fn noop(&mut self) -> imap::error::Result<()> {
match self {
Self::Plain(s) => s.noop(),
Self::Tls(s) => s.noop(),
}
}
fn logout(&mut self) -> imap::error::Result<()> {
match self {
Self::Plain(s) => s.logout(),
Self::Tls(s) => s.logout(),
}
}
}
fn connect(config: &Config) -> Result<ImapSession, String> {
let imap_cfg = &config.imap;
if imap_cfg.use_tls {
let tls = native_tls::TlsConnector::builder()
.build()
.map_err(|e| e.to_string())?;
let client = imap::connect(
(&*imap_cfg.host, imap_cfg.port),
&imap_cfg.host,
&tls,
)
.map_err(|e| e.to_string())?;
let session = client
.login(&imap_cfg.username, &imap_cfg.password)
.map_err(|(e, _)| e.to_string())?;
Ok(ImapSession::Tls(session))
} else {
let stream =
TcpStream::connect((&*imap_cfg.host, imap_cfg.port)).map_err(|e| e.to_string())?;
let client = imap::Client::new(stream);
let session = client
.login(&imap_cfg.username, &imap_cfg.password)
.map_err(|(e, _)| e.to_string())?;
Ok(ImapSession::Plain(session))
}
}
fn parse_emails(fetches: &[imap::types::Fetch]) -> Vec<Email> {
let mut emails = Vec::new();
for message in fetches {
if let Some(body) = message.header() {
let header = String::from_utf8_lossy(body);
let mut subject = String::new();
let mut from = String::new();
let mut date = String::new();
for line in header.lines() {
if let Some(val) = line.strip_prefix("Subject: ") {
subject = val.to_string();
} else if let Some(val) = line.strip_prefix("From: ") {
from = val.to_string();
} else if let Some(val) = line.strip_prefix("Date: ") {
date = val.to_string();
}
}
emails.push(Email { subject, from, date });
}
}
emails
}
const MAX_FETCH: u32 = 50;
fn fetch_range(exists: u32) -> String {
let start = exists.saturating_sub(MAX_FETCH - 1).max(1);
format!("{}:{}", start, exists)
}
fn fetch_inbox(session: &mut ImapSession) -> Result<Vec<Email>, String> {
match session {
ImapSession::Plain(s) => {
let mailbox = s.select("INBOX").map_err(|e| e.to_string())?;
if mailbox.exists == 0 {
return Ok(Vec::new());
}
let range = fetch_range(mailbox.exists);
let messages = s
.fetch(range, "BODY.PEEK[HEADER.FIELDS (SUBJECT FROM DATE)]")
.map_err(|e| e.to_string())?;
Ok(parse_emails(&messages))
}
ImapSession::Tls(s) => {
let mailbox = s.select("INBOX").map_err(|e| e.to_string())?;
if mailbox.exists == 0 {
return Ok(Vec::new());
}
let range = fetch_range(mailbox.exists);
let messages = s
.fetch(range, "BODY.PEEK[HEADER.FIELDS (SUBJECT FROM DATE)]")
.map_err(|e| e.to_string())?;
Ok(parse_emails(&messages))
}
}
}
/// Refresh inbox using NOOP + fetch. Reconnects on error.
fn refresh_inbox(
session: &mut Option<ImapSession>,
config: &Config,
) -> Result<Vec<Email>, String> {
// If we have a session, try NOOP to keep alive / detect changes
if let Some(s) = session.as_mut() {
if s.noop().is_ok() {
return fetch_inbox(s);
}
}
// Session is dead or missing — reconnect
*session = None;
let mut new_session = connect(config)?;
let result = fetch_inbox(&mut new_session);
*session = Some(new_session);
result
}
pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> Result<(), Error> { pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> Result<(), Error> {
let mut session = connect(config).ok(); let mut session = connect::connect(config).ok();
let mut inbox = refresh_inbox(&mut session, config); let mut inbox = inbox::refresh(&mut session, config);
let mut last_fetch = Instant::now(); let mut last_fetch = Instant::now();
let mut list_state = ListState::default(); let mut list_state = ListState::default();
if let Ok(emails) = &inbox { if let Ok(emails) = &inbox {
@ -162,7 +35,7 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
// --- Main loop --- // --- Main loop ---
loop { loop {
if last_fetch.elapsed() >= POLL_INTERVAL { if last_fetch.elapsed() >= POLL_INTERVAL {
inbox = refresh_inbox(&mut session, config); inbox = inbox::refresh(&mut session, config);
last_fetch = Instant::now(); last_fetch = Instant::now();
} }
@ -223,7 +96,7 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
match key.code { match key.code {
KeyCode::Char('q') | KeyCode::Esc => break, KeyCode::Char('q') | KeyCode::Esc => break,
KeyCode::Char('r') => { KeyCode::Char('r') => {
inbox = refresh_inbox(&mut session, config); inbox = inbox::refresh(&mut session, config);
last_fetch = Instant::now(); last_fetch = Instant::now();
if let Ok(emails) = &inbox { if let Ok(emails) = &inbox {
if !emails.is_empty() { if !emails.is_empty() {