Add SMTP send with TLS mode support and timeouts

- Add smtp.rs with send_email using lettre; supports none/starttls/smtps
- Replace use_tls: bool with TlsMode enum in SmtpConfig for explicit port 465 (SMTPS) support
- Add SMTP_IO_TIMEOUT (15s) for socket I/O and SMTP_WALL_TIMEOUT (30s) covering DNS + connect
- Spawn SMTP send on a dedicated thread so the IMAP worker thread is never blocked
- Update config.toml.example with tls_mode documentation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Shautvast 2026-02-19 20:07:17 +01:00
parent 7883b35dad
commit fba2623f15
6 changed files with 611 additions and 38 deletions

357
Cargo.lock generated
View file

@ -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"

View file

@ -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"
regex = "1"
lettre = { version = "0.11", default-features = false, features = ["smtp-transport", "native-tls", "builder"] }

View file

@ -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 <test@example.com>"

View file

@ -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)
}
}
}

View file

@ -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<String, String> },
Deleted(Result<(), String>),
Searched(Result<Vec<Email>, 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<CrosstermBackend<Stdout>>)
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<CrosstermBackend<Stdout>>)
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<String> = None;
// --- Main loop ---
loop {
@ -237,6 +260,12 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
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<CrosstermBackend<Stdout>>)
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<Line> = 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<CrosstermBackend<Stdout>>)
// 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<CrosstermBackend<Stdout>>)
// --- 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<CrosstermBackend<Stdout>>)
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<CrosstermBackend<Stdout>>)
}
}
// 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(())
}

71
src/smtp.rs Normal file
View file

@ -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(|_| ())
}