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:
parent
7883b35dad
commit
fba2623f15
6 changed files with 611 additions and 38 deletions
357
Cargo.lock
generated
357
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
@ -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>"
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
178
src/lib.rs
178
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<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
71
src/smtp.rs
Normal 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(|_| ())
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue