diff --git a/Cargo.lock b/Cargo.lock index 17cb517..8de5d4b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,18 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -51,6 +63,15 @@ version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" +[[package]] +name = "ar_archive_writer" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b" +dependencies = [ + "object", +] + [[package]] name = "arrayvec" version = "0.5.2" @@ -225,6 +246,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "chumsky" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eebd66744a15ded14960ab4ccdbfb51ad3b81f51f3f04a80adac98c985396c9" +dependencies = [ + "hashbrown 0.14.5", + "stacker", +] + [[package]] name = "compact_str" version = "0.9.0" @@ -486,6 +517,22 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "email-encoding" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6" +dependencies = [ + "base64 0.22.1", + "memchr", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -508,7 +555,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -724,6 +771,16 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -756,6 +813,12 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -1007,6 +1070,29 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +[[package]] +name = "lettre" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e13e10e8818f8b2a60f52cb127041d388b89f3a96a62be9ceaffa22262fef7f" +dependencies = [ + "base64 0.22.1", + "chumsky", + "email-encoding", + "email_address", + "fastrand", + "httpdate", + "idna", + "mime", + "native-tls", + "nom 8.0.0", + "percent-encoding", + "quoted_printable", + "socket2", + "tokio", + "url", +] + [[package]] name = "lexical-core" version = "0.7.6" @@ -1175,7 +1261,7 @@ dependencies = [ "libc", "log", "wasi", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1279,6 +1365,15 @@ dependencies = [ "libc", ] +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -1627,6 +1722,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "psm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3852766467df634d74f0b2d7819bf8dc483a0eb2e3b0f50f756f9cfe8b0d18d8" +dependencies = [ + "ar_archive_writer", + "cc", +] + [[package]] name = "pulldown-cmark" version = "0.13.0" @@ -1874,7 +1979,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1904,7 +2009,7 @@ version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -2088,24 +2193,6 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" -[[package]] -name = "skim" -version = "0.1.0" -dependencies = [ - "chrono", - "crossterm", - "fast_html2md", - "imap", - "mailparse", - "native-tls", - "quoted_printable", - "ratatui", - "regex", - "serde", - "toml", - "tui-markdown", -] - [[package]] name = "slab" version = "0.4.12" @@ -2118,12 +2205,35 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "stacker" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d74a23609d509411d10e2176dc2a4346e3b4aea2e7b1869f19fdedbc71c013" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.59.0", +] + [[package]] name = "static_assertions" version = "1.1.0" @@ -2221,7 +2331,7 @@ dependencies = [ "getrandom 0.4.1", "once_cell", "rustix", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -2370,6 +2480,19 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + [[package]] name = "toml" version = "1.0.2+spec-1.1.0" @@ -2477,6 +2600,25 @@ dependencies = [ "tracing", ] +[[package]] +name = "tuimail" +version = "0.1.0" +dependencies = [ + "chrono", + "crossterm", + "fast_html2md", + "imap", + "lettre", + "mailparse", + "native-tls", + "quoted_printable", + "ratatui", + "regex", + "serde", + "toml", + "tui-markdown", +] + [[package]] name = "typenum" version = "1.19.0" @@ -2794,7 +2936,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -2862,6 +3004,24 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -2871,6 +3031,135 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" version = "0.7.14" @@ -3012,6 +3301,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.116", +] + [[package]] name = "zerofrom" version = "0.1.6" diff --git a/Cargo.toml b/Cargo.toml index cc4c8cc..228a0f3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "skim" +name = "tuimail" version = "0.1.0" edition = "2024" @@ -15,4 +15,5 @@ mailparse = "0.15" fast_html2md = "0.0" tui-markdown = "0.3" quoted_printable = "0.5" -regex = "1" \ No newline at end of file +regex = "1" +lettre = { version = "0.11", default-features = false, features = ["smtp-transport", "native-tls", "builder"] } \ No newline at end of file diff --git a/config.toml.example b/config.toml.example index 6a6d2b2..73112ba 100644 --- a/config.toml.example +++ b/config.toml.example @@ -4,3 +4,15 @@ port = 143 username = "test@example.com" password = "password123" use_tls = false + +[smtp] +host = "localhost" +port = 25 +username = "test@example.com" +password = "password123" +# tls_mode options: +# none — plain text (port 25 or unencrypted 587) +# starttls — upgrades mid-session (port 587, most providers) +# smtps — TLS from first byte (port 465, Gmail "SSL") +tls_mode = "none" +from = "Test User " \ No newline at end of file diff --git a/src/config.rs b/src/config.rs index 91d7aea..82a4424 100644 --- a/src/config.rs +++ b/src/config.rs @@ -4,6 +4,30 @@ use std::fs; #[derive(Debug, Deserialize, Clone)] pub struct Config { pub imap: ImapConfig, + pub smtp: SmtpConfig, +} + +/// How the SMTP connection should be secured. +/// +/// - `none` — plain text (port 25 or 587 without TLS) +/// - `starttls` — upgrades to TLS mid-session (port 587, most providers) +/// - `smtps` — TLS from the first byte (port 465, Gmail "SSL") +#[derive(Debug, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum TlsMode { + None, + Starttls, + Smtps, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct SmtpConfig { + pub host: String, + pub port: u16, + pub username: String, + pub password: String, + pub tls_mode: TlsMode, + pub from: String, } #[derive(Debug, Deserialize, Clone)] @@ -21,4 +45,4 @@ impl Config { let config: Config = toml::from_str(&content)?; Ok(config) } -} +} \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index 96e64b8..e3e00aa 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,7 +4,7 @@ use std::sync::{mpsc, Arc}; use std::thread; use std::time::{Duration, Instant}; use crossterm::event; -use crossterm::event::{Event, KeyCode}; +use crossterm::event::{Event, KeyCode, KeyModifiers}; use ratatui::backend::CrosstermBackend; use ratatui::layout::{Constraint, Direction, Layout}; use ratatui::prelude::{Color, Line, Modifier, Span, Style}; @@ -15,6 +15,7 @@ use crate::config::Config; pub mod config; mod connect; mod inbox; +mod smtp; const POLL_INTERVAL: Duration = Duration::from_secs(30); const NAV_DEBOUNCE: Duration = Duration::from_millis(150); @@ -29,6 +30,14 @@ enum Focus { enum Mode { Normal, Search, + Compose, +} + +#[derive(PartialEq)] +enum ComposeField { + To, + Subject, + Body, } pub(crate) struct Email { @@ -44,7 +53,7 @@ enum WorkerCmd { FetchBody { seq: u32 }, Delete { seq: u32 }, Search { query: String }, - Quit, + SendEmail { to: String, subject: String, body: String }, } enum WorkerResult { @@ -53,6 +62,7 @@ enum WorkerResult { Body { seq: u32, result: Result }, Deleted(Result<(), String>), Searched(Result, String>), + Sent(Result<(), String>), } fn worker_loop( @@ -89,7 +99,14 @@ fn worker_loop( let result = inbox::search(&mut session, &query, &config); let _ = result_tx.send(WorkerResult::Searched(result)); } - WorkerCmd::Quit => break, + WorkerCmd::SendEmail { to, subject, body } => { + let smtp_cfg = config.smtp.clone(); + let tx = result_tx.clone(); + thread::spawn(move || { + let result = smtp::send_email(&smtp_cfg, &to, &subject, &body); + let _ = tx.send(WorkerResult::Sent(result)); + }); + } } } @@ -105,7 +122,7 @@ pub fn main(config: &Config, terminal: &mut Terminal>) let wanted_body_seq = Arc::new(AtomicU32::new(0)); let worker_config = config.clone(); let worker_wanted = Arc::clone(&wanted_body_seq); - let worker = thread::spawn(move || { + thread::spawn(move || { worker_loop(worker_config, cmd_rx, result_tx, worker_wanted); }); @@ -134,6 +151,12 @@ pub fn main(config: &Config, terminal: &mut Terminal>) let mut search_results_state = ListState::default(); let mut search_active = false; let mut search_loading = false; + // Compose state + let mut compose_to = String::new(); + let mut compose_subject = String::new(); + let mut compose_body = String::new(); + let mut compose_field = ComposeField::To; + let mut send_status: Option = None; // --- Main loop --- loop { @@ -237,6 +260,12 @@ pub fn main(config: &Config, terminal: &mut Terminal>) search_loading = false; search_active = false; } + WorkerResult::Sent(Ok(())) => { + send_status = Some("Sent!".to_string()); + } + WorkerResult::Sent(Err(e)) => { + send_status = Some(format!("Send failed: {}", e)); + } } } @@ -261,11 +290,85 @@ pub fn main(config: &Config, terminal: &mut Terminal>) terminal.draw(|frame| { let area = frame.area(); + + // --- Compose overlay (full-screen) --- + if mode == Mode::Compose { + let compose_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(4), + Constraint::Min(0), + Constraint::Length(1), + ]) + .split(area); + + let to_style = if compose_field == ComposeField::To { + Style::default().fg(Color::Cyan) + } else { + Style::default().fg(Color::White) + }; + let subject_style = if compose_field == ComposeField::Subject { + Style::default().fg(Color::Cyan) + } else { + Style::default().fg(Color::White) + }; + + let to_cursor = if compose_field == ComposeField::To { "_" } else { "" }; + let subject_cursor = if compose_field == ComposeField::Subject { "_" } else { "" }; + + let header_text = vec![ + Line::from(vec![ + Span::styled("To: ", to_style), + Span::styled(format!("{}{}", compose_to, to_cursor), to_style), + ]), + Line::from(vec![ + Span::styled("Subject: ", subject_style), + Span::styled(format!("{}{}", compose_subject, subject_cursor), subject_style), + ]), + ]; + let header = Paragraph::new(header_text) + .block(Block::default() + .title(Line::from(Span::styled("▶ Compose", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)))) + .borders(Borders::ALL)); + frame.render_widget(header, compose_layout[0]); + + let body_style = if compose_field == ComposeField::Body { + Style::default().fg(Color::Cyan) + } else { + Style::default().fg(Color::White) + }; + let mut body_lines: Vec = compose_body + .split('\n') + .map(|l| Line::from(Span::styled(l.to_string(), body_style))) + .collect(); + if compose_field == ComposeField::Body { + if let Some(last) = body_lines.last_mut() { + last.spans.push(Span::styled("_", body_style)); + } + } + let body_block = Paragraph::new(body_lines) + .block(Block::default() + .title(Line::from(Span::styled("Body", body_style))) + .borders(Borders::ALL)) + .wrap(ratatui::widgets::Wrap { trim: false }); + frame.render_widget(body_block, compose_layout[1]); + + let status_text = if send_status.is_some() { + format!(" {} | Ctrl+S send | Esc cancel | Tab switch field", send_status.as_deref().unwrap_or("")) + } else { + " Ctrl+S send | Esc cancel | Tab switch field".to_string() + }; + let status = Paragraph::new(status_text) + .style(Style::default().fg(Color::DarkGray)); + frame.render_widget(status, compose_layout[2]); + return; + } + let layout = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Percentage(50), - Constraint::Percentage(50), + Constraint::Fill(1), + Constraint::Fill(1), Constraint::Length(1), ]) .split(area); @@ -445,10 +548,12 @@ pub fn main(config: &Config, terminal: &mut Terminal>) // Status bar let status_text = if mode == Mode::Search { format!(" Search: {}_", search_query) + } else if let Some(ref s) = send_status { + format!(" {} | c compose | / search | q quit", s) } else if search_active { " / new search | Esc clear | q quit | ↑/↓ navigate | Tab switch pane".to_string() } else { - " / search | q quit | r refresh | d delete | ↑/↓ navigate | Tab switch pane".to_string() + " c compose | / search | q quit | r refresh | d delete | ↑/↓ navigate | Tab switch pane".to_string() }; let status = Paragraph::new(status_text) .style(Style::default().fg(Color::DarkGray)); @@ -458,7 +563,50 @@ pub fn main(config: &Config, terminal: &mut Terminal>) // --- Input handling --- if event::poll(Duration::from_millis(200))? { if let Event::Key(key) = event::read()? { - if mode == Mode::Search { + if mode == Mode::Compose { + match key.code { + KeyCode::Esc => { mode = Mode::Normal; } + KeyCode::Tab => { + compose_field = match compose_field { + ComposeField::To => ComposeField::Subject, + ComposeField::Subject => ComposeField::Body, + ComposeField::Body => ComposeField::To, + }; + } + KeyCode::Enter if compose_field != ComposeField::Body => { + compose_field = match compose_field { + ComposeField::To => ComposeField::Subject, + _ => ComposeField::Body, + }; + } + KeyCode::Enter => { compose_body.push('\n'); } + KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => { + if !compose_to.is_empty() { + let _ = cmd_tx.send(WorkerCmd::SendEmail { + to: compose_to.clone(), + subject: compose_subject.clone(), + body: compose_body.clone(), + }); + mode = Mode::Normal; + } + } + KeyCode::Backspace => { + match compose_field { + ComposeField::To => { compose_to.pop(); } + ComposeField::Subject => { compose_subject.pop(); } + ComposeField::Body => { compose_body.pop(); } + } + } + KeyCode::Char(c) => { + match compose_field { + ComposeField::To => compose_to.push(c), + ComposeField::Subject => compose_subject.push(c), + ComposeField::Body => compose_body.push(c), + } + } + _ => {} + } + } else if mode == Mode::Search { match key.code { KeyCode::Char(c) => search_query.push(c), KeyCode::Backspace => { search_query.pop(); } @@ -493,6 +641,14 @@ pub fn main(config: &Config, terminal: &mut Terminal>) search_query.clear(); mode = Mode::Search; } + KeyCode::Char('c') => { + compose_to.clear(); + compose_subject.clear(); + compose_body.clear(); + compose_field = ComposeField::To; + send_status = None; + mode = Mode::Compose; + } KeyCode::Tab => { focus = match focus { Focus::Inbox => Focus::Message, @@ -626,9 +782,9 @@ pub fn main(config: &Config, terminal: &mut Terminal>) } } - // Clean up worker - let _ = cmd_tx.send(WorkerCmd::Quit); - let _ = worker.join(); + // Signal the worker to stop and close the channel. + // Don't join — if a network op is in flight, join blocks indefinitely. + drop(cmd_tx); Ok(()) } \ No newline at end of file diff --git a/src/smtp.rs b/src/smtp.rs new file mode 100644 index 0000000..949ab68 --- /dev/null +++ b/src/smtp.rs @@ -0,0 +1,71 @@ +use std::sync::mpsc; +use std::thread; +use std::time::Duration; +use lettre::transport::smtp::authentication::Credentials; +use lettre::transport::smtp::client::{Tls, TlsParameters}; +use lettre::{Message, SmtpTransport, Transport}; +use crate::config::{SmtpConfig, TlsMode}; + +/// Per-operation socket I/O timeout (read/write). +const SMTP_IO_TIMEOUT: Duration = Duration::from_secs(15); + +/// Hard wall-clock limit covering DNS + TCP connect + full send. +const SMTP_WALL_TIMEOUT: Duration = Duration::from_secs(30); + +pub(crate) fn send_email( + cfg: &SmtpConfig, + to: &str, + subject: &str, + body: &str, +) -> Result<(), String> { + let email = Message::builder() + .from(cfg.from.parse().map_err(|e: lettre::address::AddressError| e.to_string())?) + .to(to.parse().map_err(|e: lettre::address::AddressError| e.to_string())?) + .subject(subject) + .body(body.to_string()) + .map_err(|e| e.to_string())?; + + let creds = Credentials::new(cfg.username.clone(), cfg.password.clone()); + + let transport = match cfg.tls_mode { + TlsMode::Starttls => { + // STARTTLS: plain connect, then upgrade (port 587) + SmtpTransport::relay(&cfg.host) + .map_err(|e| e.to_string())? + .port(cfg.port) + .credentials(creds) + .timeout(Some(SMTP_IO_TIMEOUT)) + .build() + } + TlsMode::Smtps => { + // SMTPS: TLS from the first byte (port 465) + let tls = TlsParameters::new(cfg.host.clone()).map_err(|e| e.to_string())?; + SmtpTransport::relay(&cfg.host) + .map_err(|e| e.to_string())? + .port(cfg.port) + .tls(Tls::Wrapper(tls)) + .credentials(creds) + .timeout(Some(SMTP_IO_TIMEOUT)) + .build() + } + TlsMode::None => { + // Plain text, no TLS at all (port 25 or unencrypted 587) + SmtpTransport::builder_dangerous(&cfg.host) + .port(cfg.port) + .credentials(creds) + .timeout(Some(SMTP_IO_TIMEOUT)) + .build() + } + }; + + // Spawn the blocking send on a thread so we can impose a hard wall-clock + // timeout that covers DNS resolution and TCP connect (not just I/O). + let (tx, rx) = mpsc::channel(); + thread::spawn(move || { + let _ = tx.send(transport.send(&email).map_err(|e| e.to_string())); + }); + + rx.recv_timeout(SMTP_WALL_TIMEOUT) + .map_err(|_| format!("SMTP timed out after {}s", SMTP_WALL_TIMEOUT.as_secs()))? + .map(|_| ()) +} \ No newline at end of file