Compare commits

...

10 commits

Author SHA1 Message Date
Shautvast
11941a5bea doc update2 2026-03-02 16:51:06 +01:00
Shautvast
696c3bec9f doc update 2026-03-02 16:45:37 +01:00
Shautvast
a2d6e72b7a less keychain access 2026-03-02 16:41:19 +01:00
Shautvast
5a3ac2eed9 secured bridge communication 2026-03-02 16:27:46 +01:00
Shautvast
9375b5b96f updated docs5 2026-03-02 15:41:52 +01:00
Shautvast
b64d1411d2 updated docs4 2026-03-02 15:35:49 +01:00
Shautvast
56b27e1692 updated docs3 2026-03-02 15:30:09 +01:00
Shautvast
ef5127e0de updated docs2 2026-03-02 15:28:02 +01:00
Shautvast
1eb4affdb6 updated docs 2026-03-02 15:25:52 +01:00
Shautvast
e2badaa170 update dependencies 2026-03-02 14:58:44 +01:00
18 changed files with 636 additions and 855 deletions

113
CLAUDE.md
View file

@ -1,113 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
**tui_mail** is a TUI email client built with Rust and Ratatui. It supports standard IMAP servers (including Gmail) and ProtonMail (via an in-process bridge). It displays inbox messages with a split-pane interface: email list on top, message preview on the bottom.
**User documentation:** see [`USAGE.md`](USAGE.md) for setup instructions, keyboard shortcuts, and configuration reference.
## Build and Run Commands
```bash
# Build the project (standard IMAP)
cargo build
# Run the application
cargo run
# Build/run with ProtonMail support
cargo build --features proton
cargo run --features proton
# Build optimized release version
cargo build --release
# Check code without building
cargo check
# Format code
cargo fmt
# Run clippy linter
cargo clippy
```
## Test Mail Server
A Docker-based IMAP mail server is available for testing:
```bash
# Start the mail server
docker-compose up -d
# Create a test user
docker exec -it mailserver setup email add test@example.com password123
# Stop the mail server
docker-compose down
```
Connection details: localhost:143 (IMAP) or localhost:993 (IMAPS). See `MAIL_SERVER_SETUP.md` for detailed usage including Gmail configuration.
## Architecture
### Main app (`src/`)
- **`src/main.rs`** — Terminal setup/teardown, delegates to `lib::main`
- **`src/lib.rs`** — Main event loop, UI rendering, worker thread coordination
- **`src/inbox.rs`** — IMAP inbox operations (refresh, fetch older, fetch body, search, delete)
- **`src/connect.rs`** — IMAP connection handling (plain TCP and TLS)
- **`src/config.rs`** — Configuration loading from `config.toml`; provider selection (IMAP vs Proton)
- **`src/credentials.rs`** — OS keychain access via the `keyring` crate
- **`src/smtp.rs`** — Outgoing mail via SMTP using `lettre`
- **`src/setup.rs`** — First-run interactive setup wizard
- **`src/store.rs`** — Local encrypted body cache (AES-256-GCM, key stored in `storage.key` file)
### ProtonMail bridge (`proton-bridge/`)
A separate workspace crate that runs as an in-process local IMAP/SMTP server.
- **`proton-bridge/src/lib.rs`** — Entry point (`start()`); authenticates with ProtonMail API, binds local ports, spawns async tasks
- **`proton-bridge/src/imap_server.rs`** — Local IMAP server (LOGIN, SELECT, FETCH, SEARCH, STORE, EXPUNGE)
- **`proton-bridge/src/smtp_server.rs`** — Local SMTP server for outgoing mail
- **`proton-bridge/src/api.rs`** — ProtonMail REST API client (message list, fetch, delete, send)
- **`proton-bridge/src/auth.rs`** — SRP authentication and session management
- **`proton-bridge/src/crypto.rs`** — PGP key derivation and message decryption/encryption
- **`proton-bridge/src/store.rs`** — In-memory message metadata store for the bridge
- **`proton-bridge/src/srp.rs`** — SRP-6a implementation matching ProtonMail's go-srp
### Key patterns
- IMAP operations run in a **background worker thread** communicating via `mpsc` channels, keeping the UI responsive
- Emails are loaded in **batches of 50**, with lazy loading when scrolling past the end
- A **navigation debounce** (150 ms) avoids firing body fetches on every keypress while scrolling
- The worker checks `wanted_body_seq` (an `AtomicU32`) to drop stale body requests
- **Body cache**: fetched message bodies are AES-256-GCM encrypted and written to `{data_dir}/tuimail/bodies/{hash}.enc`; cache key is the `Message-ID` header value
- Credentials (IMAP/SMTP/Proton passwords) are stored in the **OS keychain** via `keyring`
- The encryption key for the body cache is stored in `{data_dir}/tuimail/storage.key` (mode 0600) to avoid repeated keychain prompts
- **Tab** switches focus between inbox list and preview pane
- Selection is preserved across refreshes by matching IMAP sequence numbers
## Key Dependencies
- **ratatui (0.30)**: TUI framework providing widgets, layouts, and rendering
- **crossterm (0.29)**: Cross-platform terminal manipulation (raw mode, events, alternate screen)
- **imap (2.4)**: IMAP protocol client
- **native-tls (0.2)**: TLS support for secure IMAP connections
- **lettre (0.11)**: SMTP client for sending mail
- **chrono (0.4)**: Date parsing and timezone conversion
- **mailparse (0.15)**: MIME email paursing for body extraction
- **aes-gcm (0.10)**: AES-256-GCM authenticated encryption for the body cache
- **keyring (3)**: OS keychain access (apple-native / linux-native / windows-native)
- **dirs (5)**: Platform-correct data directory paths
## Development Notes
- Uses Rust edition 2024
- Terminal is set to raw mode to capture individual key presses
- The alternate screen prevents terminal history pollution
- `config.toml` contains credentials and is gitignored — see `config.toml.example` for the format
- `proton-bridge/bridge.toml` contains ProtonMail credentials and is gitignored
- The ProtonMail bridge imap_server SELECT response must **not** include `UIDVALIDITY`/`UIDNEXT` lines — imap-proto 0.10 (used by imap 2.4) does not fully consume them, causing tag desync on subsequent commands

282
Cargo.lock generated
View file

@ -336,6 +336,28 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "aws-lc-rs"
version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9a7b350e3bb1767102698302bc37256cbd48422809984b98d292c40e2579aa9"
dependencies = [
"aws-lc-sys",
"zeroize",
]
[[package]]
name = "aws-lc-sys"
version = "0.37.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b092fe214090261288111db7a2b2c2118e5a7f30dc2569f1732c4069a6840549"
dependencies = [
"cc",
"cmake",
"dunce",
"fs_extra",
]
[[package]]
name = "base16ct"
version = "0.2.0"
@ -362,9 +384,9 @@ checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "bcrypt"
version = "0.15.1"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e65938ed058ef47d92cf8b346cc76ef48984572ade631927e9937b5ffc7662c7"
checksum = "2b1866ecef4f2d06a0bb77880015fdf2b89e25a1c2e5addacb87e459c86dc67e"
dependencies = [
"base64 0.22.1",
"blowfish 0.9.1",
@ -399,9 +421,9 @@ checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb"
[[package]]
name = "bitfield"
version = "0.14.0"
version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d7e60934ceec538daadb9d8432424ed043a904d8e0243f3c6446bce549a46ac"
checksum = "f798d2d157e547aa99aab0967df39edd0b70307312b6f8bd2848e6abe40896e0"
[[package]]
name = "bitflags"
@ -587,6 +609,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2"
dependencies = [
"find-msvc-tools",
"jobserver",
"libc",
"shlex",
]
@ -676,6 +700,15 @@ dependencies = [
"digest 0.10.7",
]
[[package]]
name = "cmake"
version = "0.1.57"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d"
dependencies = [
"cc",
]
[[package]]
name = "colorchoice"
version = "1.0.4"
@ -888,7 +921,7 @@ dependencies = [
"cpufeatures",
"curve25519-dalek-derive",
"digest 0.10.7",
"fiat-crypto 0.2.9",
"fiat-crypto",
"rustc_version",
"subtle",
"zeroize",
@ -1156,23 +1189,23 @@ dependencies = [
[[package]]
name = "dirs"
version = "5.0.1"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
dependencies = [
"dirs-sys",
]
[[package]]
name = "dirs-sys"
version = "0.4.1"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.48.0",
"windows-sys 0.61.2",
]
[[package]]
@ -1226,6 +1259,12 @@ dependencies = [
"dtoa",
]
[[package]]
name = "dunce"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]]
name = "eax"
version = "0.5.0"
@ -1277,17 +1316,6 @@ dependencies = [
"zeroize",
]
[[package]]
name = "ed448-goldilocks"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87b5fa9e9e3dd5fe1369f380acd3dcdfa766dbd0a1cd5b048fb40e38a6a78e79"
dependencies = [
"fiat-crypto 0.1.20",
"hex",
"subtle",
]
[[package]]
name = "either"
version = "1.15.0"
@ -1477,12 +1505,6 @@ dependencies = [
"subtle",
]
[[package]]
name = "fiat-crypto"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e825f6987101665dea6ec934c09ec6d721de7bc1bf92248e1d5810c8cd636b77"
[[package]]
name = "fiat-crypto"
version = "0.2.9"
@ -1570,6 +1592,12 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "fs_extra"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]]
name = "futures-channel"
version = "0.3.32"
@ -2205,6 +2233,16 @@ dependencies = [
"syn 2.0.116",
]
[[package]]
name = "jobserver"
version = "0.1.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
dependencies = [
"getrandom 0.3.4",
"libc",
]
[[package]]
name = "js-sys"
version = "0.3.85"
@ -2452,9 +2490,9 @@ dependencies = [
[[package]]
name = "mailparse"
version = "0.15.0"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3da03d5980411a724e8aaf7b61a7b5e386ec55a7fb49ee3d0ff79efc7e5e7c7e"
checksum = "60819a97ddcb831a5614eb3b0174f3620e793e97e09195a395bfa948fd68ed2f"
dependencies = [
"charset",
"data-encoding",
@ -2956,6 +2994,16 @@ dependencies = [
"subtle",
]
[[package]]
name = "pem"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
dependencies = [
"base64 0.22.1",
"serde_core",
]
[[package]]
name = "pem-rfc7468"
version = "0.7.0"
@ -3016,9 +3064,9 @@ dependencies = [
[[package]]
name = "pgp"
version = "0.14.2"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1877a97fd422433220ad272eb008ec55691944b1200e9eb204e3cb2cb69d34e9"
checksum = "30249ac8a98b356b473b04bc5358c75a260aa96a295d0743ce752fe7b173f235"
dependencies = [
"aes",
"aes-gcm",
@ -3074,10 +3122,9 @@ dependencies = [
"sha3",
"signature",
"smallvec",
"thiserror 1.0.69",
"thiserror 2.0.18",
"twofish",
"x25519-dalek",
"x448",
"zeroize",
]
@ -3371,10 +3418,8 @@ dependencies = [
name = "proton-bridge"
version = "0.1.0"
dependencies = [
"aes",
"base64 0.22.1",
"bcrypt",
"cfb-mode",
"chrono",
"env_logger",
"keyring",
@ -3382,12 +3427,14 @@ dependencies = [
"pgp",
"pwhash",
"rand 0.8.5",
"rcgen",
"reqwest",
"rustls",
"serde",
"serde_json",
"sha1",
"sha2 0.10.9",
"tokio",
"tokio-rustls",
"toml",
"tracing",
]
@ -3622,6 +3669,19 @@ dependencies = [
"unicode-width",
]
[[package]]
name = "rcgen"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2"
dependencies = [
"pem",
"ring",
"rustls-pki-types",
"time",
"yasna",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
@ -3633,13 +3693,13 @@ dependencies = [
[[package]]
name = "redox_users"
version = "0.4.6"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
dependencies = [
"getrandom 0.2.17",
"libredox",
"thiserror 1.0.69",
"thiserror 2.0.18",
]
[[package]]
@ -3723,6 +3783,20 @@ dependencies = [
"subtle",
]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
name = "ripemd"
version = "0.1.3"
@ -3809,6 +3883,21 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
dependencies = [
"aws-lc-rs",
"log",
"once_cell",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pki-types"
version = "1.14.0"
@ -3818,6 +3907,18 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
dependencies = [
"aws-lc-rs",
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]]
name = "rustversion"
version = "1.0.22"
@ -4521,6 +4622,16 @@ dependencies = [
"tokio",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
"rustls",
"tokio",
]
[[package]]
name = "toml"
version = "1.0.2+spec-1.1.0"
@ -4699,6 +4810,7 @@ dependencies = [
"ratatui",
"regex",
"serde",
"serde_json",
"toml",
"tui-markdown",
]
@ -4786,6 +4898,12 @@ dependencies = [
"subtle",
]
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "url"
version = "2.5.8"
@ -5157,15 +5275,6 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-sys"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
dependencies = [
"windows-targets 0.48.5",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
@ -5202,21 +5311,6 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-targets"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
dependencies = [
"windows_aarch64_gnullvm 0.48.5",
"windows_aarch64_msvc 0.48.5",
"windows_i686_gnu 0.48.5",
"windows_i686_msvc 0.48.5",
"windows_x86_64_gnu 0.48.5",
"windows_x86_64_gnullvm 0.48.5",
"windows_x86_64_msvc 0.48.5",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
@ -5250,12 +5344,6 @@ dependencies = [
"windows_x86_64_msvc 0.53.1",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
@ -5268,12 +5356,6 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]]
name = "windows_aarch64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
@ -5286,12 +5368,6 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]]
name = "windows_i686_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
@ -5316,12 +5392,6 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]]
name = "windows_i686_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
@ -5334,12 +5404,6 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]]
name = "windows_x86_64_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
@ -5352,12 +5416,6 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
@ -5370,12 +5428,6 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]]
name = "windows_x86_64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
@ -5503,17 +5555,6 @@ dependencies = [
"zeroize",
]
[[package]]
name = "x448"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4cd07d4fae29e07089dbcacf7077cd52dce7760125ca9a4dd5a35ca603ffebb"
dependencies = [
"ed448-goldilocks",
"hex",
"rand_core 0.5.1",
]
[[package]]
name = "xdg-home"
version = "1.3.0"
@ -5539,6 +5580,15 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
[[package]]
name = "yasna"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd"
dependencies = [
"time",
]
[[package]]
name = "yoke"
version = "0.8.1"

View file

@ -6,11 +6,8 @@ name = "tuimail"
version = "0.1.0"
edition = "2024"
[features]
proton = ["dep:proton-bridge"]
[dependencies]
proton-bridge = { path = "proton-bridge", optional = true }
proton-bridge = { path = "proton-bridge", optional = false }
ratatui = "0.30"
crossterm = "0.29"
imap = "2.4"
@ -18,15 +15,16 @@ native-tls = "0.2"
serde = { version = "1.0", features = ["derive"] }
toml = "1.0"
chrono = "0.4"
mailparse = "0.15"
mailparse = "0.16"
fast_html2md = "0.0"
tui-markdown = "0.3"
quoted_printable = "0.5"
regex = "1"
lettre = { version = "0.11", default-features = false, features = ["smtp-transport", "native-tls", "builder"] }
dirs = "5"
dirs = "6.0"
rand = { version = "0.8", features = ["getrandom"] }
aes-gcm = "0.10"
serde_json = "1.0"
[target.'cfg(target_os = "macos")'.dependencies]
keyring = { version = "3", features = ["apple-native"] }

View file

@ -1,124 +0,0 @@
# Mail Server Setup
## Gmail Configuration
### 1. Enable 2-Step Verification
App Passwords require 2-Step Verification to be enabled on your Google account.
1. Go to https://myaccount.google.com/security
2. Under "How you sign in to Google", click **2-Step Verification**
3. Follow the prompts to enable it
### 2. Create an App Password
1. Go to https://myaccount.google.com/apppasswords
2. Enter a name (e.g. "Mail TUI") and click **Create**
3. Google will display a 16-character password — copy it
### 3. Configure tuimail
Run the setup wizard:
```bash
cargo run -- --configure
```
When prompted for provider choose `imap`, then enter:
- IMAP host: `imap.gmail.com`, port: `993`, TLS: `true`
- Username: your Gmail address
- Password: the 16-character App Password from step 2 (spaces are optional)
- SMTP host: `smtp.gmail.com`, port: `465`, TLS mode: `smtps`
Passwords are stored securely in the OS keychain — they are never written to `config.toml`.
## Local Test Server (Docker)
### Quick Start
1. **Start the mail server:**
```bash
docker-compose up -d
```
2. **Create a test user:**
```bash
docker exec -it mailserver setup email add test@example.com password123
```
3. **Verify the server is running:**
```bash
docker-compose ps
```
### Configure tuimail for the local server
```bash
cargo run -- --configure
```
Choose provider `imap` and enter:
- IMAP host: `localhost`, port: `143`, TLS: `false`
- Username: `test@example.com`, password: `password123`
- SMTP host: `localhost`, port: `25`, TLS mode: `none`
### IMAP Connection Details
- **Host:** localhost
- **IMAP Port:** 143 (unencrypted) or 993 (SSL/TLS)
- **Username:** test@example.com
- **Password:** password123
### Useful Commands
```bash
# Stop the mail server
docker-compose down
# View logs
docker-compose logs -f mailserver
# List all email accounts
docker exec -it mailserver setup email list
# Add another user
docker exec -it mailserver setup email add user2@example.com pass456
# Delete a user
docker exec -it mailserver setup email del test@example.com
# Access the container shell
docker exec -it mailserver bash
```
### Testing with telnet
You can test IMAP connectivity:
```bash
telnet localhost 143
```
Then try IMAP commands:
```
a1 LOGIN test@example.com password123
a2 LIST "" "*"
a3 SELECT INBOX
a4 LOGOUT
```
### Send Test Email
```bash
# From within the container
docker exec -it mailserver bash
echo "Test email body" | mail -s "Test Subject" test@example.com
```
Or use SMTP (port 25/587) from your application.
## Troubleshooting
- Gmail: if login fails, verify that 2-Step Verification is enabled and you're using an App Password (not your regular password)
- Docker: check logs with `docker-compose logs mailserver`
- Ensure ports aren't already in use
- Data persists in `./docker-data/` directory

View file

@ -1,83 +0,0 @@
# ProtonMail Mini-Bridge
A minimal ProtonMail Bridge implementation in Rust that exposes local IMAP and SMTP servers,
allowing `skim` (and any other standard email client) to connect without code changes.
The full ProtonMail Bridge is ~50,000 lines of Go. This mini-bridge targets only the subset
of functionality that `skim` needs: one account, INBOX only, one concurrent client.
## Components
### 1. Project scaffold
New binary crate with `Cargo.toml` dependencies (`tokio`, `reqwest`, `proton-srp`, `rpgp`,
`serde`, `toml`). Config file format covering ProtonMail credentials and local bind ports.
### 2. ProtonMail authentication
SRP 6a login flow against the Proton API:
- POST `/auth/info` with username → receive modulus, server ephemeral, salt
- Compute SRP proof locally using `proton-srp`
- POST `/auth/login` with client proof → receive access token + encrypted private keys
- Handle optional TOTP 2FA interactively on first run
- Persist session (access token + refresh token) to disk to avoid re-authenticating on restart
### 3. ProtonMail API client
Thin `reqwest`-based HTTP wrapper around the endpoints the bridge needs:
- List messages (with pagination)
- Fetch single message (metadata + encrypted body)
- Delete message
- Fetch recipient public key (for outbound encryption)
- Send message (modelled from the open-source `ProtonMail/proton-bridge` Go implementation)
### 4. Crypto layer
Using `rpgp` or `proton-crypto-rs`:
- Decrypt the user's private key (delivered encrypted by the API, unlocked with the mailbox password)
- Decrypt incoming message bodies with the private key
- Encrypt outbound messages to recipient public keys
### 5. Message store
In-memory (optionally persisted) mapping between IMAP sequence numbers and Proton message IDs.
IMAP uses stable sequential integers; Proton uses opaque string IDs. The store must:
- Assign sequence numbers to messages in order
- Renumber correctly after deletes
- Survive restarts without breaking existing client state
### 6. IMAP server
TCP listener on `localhost:143` (configurable). Implements the nine commands `skim` uses:
| Command | Purpose |
|---------|---------|
| `LOGIN` | Accept local credentials (no real auth needed) |
| `NOOP` | Keepalive / connection check |
| `SELECT INBOX` | Open mailbox, report message count |
| `FETCH range BODY.PEEK[HEADER.FIELDS (SUBJECT FROM DATE)]` | List emails |
| `FETCH seq BODY.PEEK[]` | Fetch full message body |
| `SEARCH OR SUBJECT "..." FROM "..."` | Search by subject or sender |
| `STORE seq +FLAGS (\Deleted)` | Mark for deletion |
| `EXPUNGE` | Delete marked messages |
| `LOGOUT` | Disconnect |
Each command translates to API client + crypto layer calls via the message store.
### 7. SMTP server
TCP listener on `localhost:587` (configurable). Minimal implementation:
- EHLO, AUTH, MAIL FROM, RCPT TO, DATA, QUIT
- On DATA completion: hand the message to the crypto layer to encrypt, then POST via API client
## Build order
Components 2 → 3 → 4 can be built and tested with a simple CLI harness before any
network server exists. Component 5 is pure logic with no I/O. Components 6 and 7 are
the final pieces and can be validated by pointing `tuimail` at localhost.
## References
- [ProtonMail/proton-bridge](https://github.com/ProtonMail/proton-bridge) — official Bridge (Go, open source)
- [ProtonMail/go-proton-api](https://github.com/ProtonMail/go-proton-api) — official Go API client
- [ProtonMail/proton-crypto-rs](https://github.com/ProtonMail/proton-crypto-rs) — official Rust crypto crates
- [ProtonMail/proton-srp](https://github.com/ProtonMail/proton-srp) — official Rust SRP implementation
- [rpgp](https://github.com/rpgp/rpgp) — pure Rust OpenPGP implementation
More work:
- local storge in the proton-bridge. Decide whether to store the message body pgp-encrypted, as it came from the proton server, or
use decrypt it (and serve to the client using imap) and create a AES-256-GCM encrypted cache
- email replies in the tui client have not yet been implemented

184
README.md Normal file
View file

@ -0,0 +1,184 @@
# Tuimail
Tuimail is a terminal email client. It allows you to use your keyboard to navigate, search and send messages.
### Safety features
- no html rendering (conversion to plain text)
- no tracking pixels
- no javascript
- no local passwords (uses your OS keychain)
- safe local message storage using AES-256-GCM.
- safe communication to remote hosts (TLS, PGP for Proton)
---
# Configuring
Sorry, there is no binary release for now. This setup requires that you have rust cargo installed.
```bash
cargo run
```
The first time you run it asks you to enter the config settings.
You can reconfigure later using:
```bash
cargo run -- --configure
```
When prompted for provider choose `imap/proton`, choose your provider:
For imap using gmail:
- IMAP host: `imap.gmail.com`, port: `993`, TLS: `true`
- Username
- Password
- SMTP host: `smtp.gmail.com`, port: `465`, TLS mode: `smtps`
Different imap providers may have other hosts and ports...
For Proton (Tuimail does not require the proton-bridge)
- Username: your proton account user name
- Password:
Tuimail stores passwords securely in the **OS keychain** (macOS Keychain,
GNOME Keyring, KWallet, Windows Credential Manager). No passwords are ever
written to disk in plain text.
Note: every time you recreate the binary file using cargo,
macOS will need reapproval for access to the keychain.
## Extra Configuration needed for Gmail
### 1. Enable 2-Step Verification
App Passwords require 2-Step Verification to be enabled on your Google account.
1. Go to https://myaccount.google.com/security
2. Under "How you sign in to Google", click **2-Step Verification**
3. Follow the prompts to enable it
### 2. Create an App Password
1. Go to https://myaccount.google.com/apppasswords
2. Enter a name (e.g. "Mail TUI") and click **Create**
3. Google will display a 16-character password — copy it
## Interface
```
┌─────────────────────────────────────────────────────┐
│ ▶ Inbox (42 messages) │ ← active pane (cyan)
│ 2025-01-15 10:32 Alice <alice@example.com> Hello│
│>> 2025-01-14 09:11 Bob <bob@example.com> Re: …│ ← selected row
│ 2025-01-13 17:44 Carol <carol@example.com> Mtg │
├─────────────────────────────────────────────────────┤
│ Message │ ← inactive pane
│ │
│ Hi there, │
│ Just checking in… │
│ │
├─────────────────────────────────────────────────────┤
│ c compose | r reply | / search | q quit | … │ ← status bar
└─────────────────────────────────────────────────────┘
```
The active pane is highlighted in cyan with a `▶` prefix. `Tab` switches focus
between the two panes.
---
## Keyboard Reference
### Navigation
| Key | Action |
|-------------|--------|
| `↑` / `k` | Move up in inbox or scroll message up |
| `↓` / `j` | Move down in inbox or scroll message down |
| `Tab` | Switch focus between inbox list and message preview |
| `q` / `Esc` | Quit (or clear search results) |
Scrolling past the last loaded message automatically fetches the next batch of
50 older emails.
### Email Actions
| Key | Action |
|------------|--------|
| `r` | **Reply** to the selected email |
| `d` | **Delete** the selected email (moves to Trash) |
| `u` / `F5` | **Refresh** the inbox manually |
### Search
| Key | Action |
|----------------|--------|
| `/` | Open the search bar |
| *(type query)* | Filter by subject or sender |
| `Enter` | Run the search |
| `Esc` | Clear search results and return to inbox |
Search runs an IMAP `SEARCH OR SUBJECT … FROM …` query on the server so it
works across your entire mailbox, not just the loaded batch.
### Compose
| Key | Action |
|----------|--------|
| `c` | Open compose window |
| `r` | Open compose pre-filled for replying to the selected email |
| `Tab` | Cycle focus: To → Subject → Body → To |
| `Enter` | Move to next field (To / Subject); insert newline in Body |
| `Ctrl+S` | Send the email |
| `Esc` | Cancel and discard the draft |
---
## Composing and Replying
Press **`c`** to compose a new email. The compose window opens full-screen:
```
┌─ ▶ Compose ──────────────────────┐
│ To: alice@example.com │
│ Subject: Hello │
├─ Body ───────────────────────────┤
│ Hi Alice, │
│ │
│ _ │ ← cursor
└──────────────────────────────────┘
Ctrl+S send | Esc cancel | Tab switch field
```
Press **`r`** to reply. The compose window opens with:
- **To** pre-filled with the sender's address
- **Subject** set to `Re: <original subject>`
- **Cursor** placed in the Body field, ready to type
- The original message shown below a separator (dimmed) — included in the
sent email automatically
```
┌─ ▶ Compose ──────────────────────┐
│ To: bob@example.com │
│ Subject: Re: Weekend plans │
├─ Body ───────────────────────────┤
│ _ │ ← type your reply here
│ ─────────────────────────────── │
│ On 2025-01-14 09:11, Bob wrote: │
│ > Are you free Saturday? │
└──────────────────────────────────┘
```
> If the message body hasn't finished loading when you press `r`, the quote
> is omitted. Press `Esc`, wait a moment for the preview to appear, then
> press `r` again.
---
## Auto-refresh
The inbox refreshes automatically every 30 seconds in the background. A
`[loading…]` indicator appears in the inbox title while a refresh is in
progress. Your current selection is preserved across refreshes.
---

249
USAGE.md
View file

@ -1,249 +0,0 @@
# tuimail — User Guide
tuimail is a terminal email client. It shows your inbox in a split-pane view:
the email list on top, the message preview on the bottom.
---
## Setup
tuimail stores passwords securely in the **OS keychain** (macOS Keychain,
GNOME Keyring, KWallet, Windows Credential Manager). No passwords are ever
written to disk in plain text.
### First-time setup
Simply run tuimail — if no config file exists it launches an interactive
wizard automatically:
```bash
cargo run
```
The wizard prompts for your provider, server settings, and passwords, then
saves the config file and stores all passwords in the OS keychain.
### Re-configure / update credentials
```bash
cargo run -- --configure
```
All prompts show current values in brackets. Press Enter to keep a value, or
type a new one. Password prompts show `[stored]` when a value already exists
in the keychain.
### Headless / CI environments (env-var fallback)
If the OS keychain is unavailable, export the passwords as environment
variables:
| Variable | Credential |
|----------|------------|
| `TUIMAIL_IMAP_PASSWORD` | IMAP password |
| `TUIMAIL_SMTP_PASSWORD` | SMTP password |
| `TUIMAIL_PROTON_PASSWORD` | ProtonMail login password |
| `TUIMAIL_PROTON_MAILBOX_PASSWORD` | ProtonMail mailbox password (two-password mode) |
Example:
```bash
TUIMAIL_IMAP_PASSWORD=hunter2 cargo run
```
**Common provider settings**
| Provider | IMAP host | IMAP port | use_tls | SMTP host | SMTP port | tls_mode |
|----------|-----------|-----------|---------|-----------|-----------|----------|
| Gmail | imap.gmail.com | 993 | true | smtp.gmail.com | 465 | smtps |
| Outlook/Hotmail | outlook.office365.com | 993 | true | smtp.office365.com | 587 | starttls |
| ProtonMail | see ProtonMail section below ||||| |
| Local test server | localhost | 143 | false | localhost | 25 | none |
> **Gmail note:** You must use an [App Password](https://myaccount.google.com/apppasswords),
> not your regular password. Enable 2-Step Verification first, then generate an
> App Password for "Mail".
### ProtonMail
tuimail can talk to ProtonMail directly — no separate bridge process needed.
The bridge starts automatically in-process when `provider = "proton"` is set.
**1. Build with ProtonMail support:**
```bash
cargo build --features proton
```
**2. Run the setup wizard:**
```bash
cargo run --features proton -- --configure
```
The wizard prompts for your ProtonMail username and password (stored in
keychain), two-password mode, and bridge ports. The bridge local password is
auto-generated and stored in the keychain.
**3. Run:**
```bash
cargo run --features proton
```
The bridge authenticates with ProtonMail before the TUI opens. Messages are
decrypted on the fly; sent mail is encrypted end-to-end automatically.
Then run (standard providers):
```bash
cargo run --release
```
---
## Interface
```
┌─────────────────────────────────────────────────────┐
│ ▶ Inbox (42 messages) │ ← active pane (cyan)
│ 2025-01-15 10:32 Alice <alice@example.com> Hello│
│>> 2025-01-14 09:11 Bob <bob@example.com> Re: …│ ← selected row
│ 2025-01-13 17:44 Carol <carol@example.com> Mtg │
├─────────────────────────────────────────────────────┤
│ Message │ ← inactive pane
│ │
│ Hi there, │
│ Just checking in… │
│ │
├─────────────────────────────────────────────────────┤
│ c compose | r reply | / search | q quit | … │ ← status bar
└─────────────────────────────────────────────────────┘
```
The active pane is highlighted in cyan with a `▶` prefix. `Tab` switches focus
between the two panes.
---
## Keyboard Reference
### Navigation
| Key | Action |
|-----|--------|
| `↑` / `k` | Move up in inbox or scroll message up |
| `↓` / `j` | Move down in inbox or scroll message down |
| `Tab` | Switch focus between inbox list and message preview |
| `q` / `Esc` | Quit (or clear search results) |
Scrolling past the last loaded message automatically fetches the next batch of
50 older emails.
### Email Actions
| Key | Action |
|-----|--------|
| `r` | **Reply** to the selected email |
| `d` | **Delete** the selected email (moves to Trash) |
| `u` / `F5` | **Refresh** the inbox manually |
### Search
| Key | Action |
|-----|--------|
| `/` | Open the search bar |
| *(type query)* | Filter by subject or sender |
| `Enter` | Run the search |
| `Esc` | Clear search results and return to inbox |
Search runs an IMAP `SEARCH OR SUBJECT … FROM …` query on the server so it
works across your entire mailbox, not just the loaded batch.
### Compose
| Key | Action |
|-----|--------|
| `c` | Open compose window |
| `r` | Open compose pre-filled for replying to the selected email |
| `Tab` | Cycle focus: To → Subject → Body → To |
| `Enter` | Move to next field (To / Subject); insert newline in Body |
| `Ctrl+S` | Send the email |
| `Esc` | Cancel and discard the draft |
---
## Composing and Replying
Press **`c`** to compose a new email. The compose window opens full-screen:
```
┌─ ▶ Compose ──────────────────────┐
│ To: alice@example.com │
│ Subject: Hello │
├─ Body ───────────────────────────┤
│ Hi Alice, │
│ │
│ _ │ ← cursor
└──────────────────────────────────┘
Ctrl+S send | Esc cancel | Tab switch field
```
Press **`r`** to reply. The compose window opens with:
- **To** pre-filled with the sender's address
- **Subject** set to `Re: <original subject>`
- **Cursor** placed in the Body field, ready to type
- The original message shown below a separator (dimmed) — included in the
sent email automatically
```
┌─ ▶ Compose ──────────────────────┐
│ To: bob@example.com │
│ Subject: Re: Weekend plans │
├─ Body ───────────────────────────┤
│ _ │ ← type your reply here
│ ─────────────────────────────── │
│ On 2025-01-14 09:11, Bob wrote: │
│ > Are you free Saturday? │
└──────────────────────────────────┘
```
> If the message body hasn't finished loading when you press `r`, the quote
> is omitted. Press `Esc`, wait a moment for the preview to appear, then
> press `r` again.
---
## Auto-refresh
The inbox refreshes automatically every 30 seconds in the background. A
`[loading…]` indicator appears in the inbox title while a refresh is in
progress. Your current selection is preserved across refreshes.
---
## Configuration Reference
### `[imap]`
| Key | Type | Description |
|-----|------|-------------|
| `host` | string | IMAP server hostname |
| `port` | integer | IMAP port (usually 993 with TLS, 143 without) |
| `username` | string | Login username (usually your full email address) |
| `use_tls` | bool | `true` for IMAPS (port 993), `false` for plain/STARTTLS |
> Password is stored in the OS keychain. Use `--configure` to set or update it.
### `[smtp]`
| Key | Type | Description |
|-----|------|-------------|
| `host` | string | SMTP server hostname |
| `port` | integer | SMTP port |
| `username` | string | Login username |
| `tls_mode` | string | `none`, `starttls`, or `smtps` |
| `from` | string | Sender address shown to recipients, e.g. `Name <addr>` |
> Password is stored in the OS keychain. Use `--configure` to set or update it.

View file

@ -1,30 +0,0 @@
version: '3.8'
services:
mailserver:
image: mailserver/docker-mailserver:latest
container_name: mailserver
hostname: mail.example.com
ports:
- "25:25" # SMTP
- "143:143" # IMAP
- "587:587" # SMTP Submission
- "993:993" # IMAPS
volumes:
- ./docker-data/mail-data:/var/mail
- ./docker-data/mail-state:/var/mail-state
- ./docker-data/mail-logs:/var/log/mail
- ./docker-data/config:/tmp/docker-mailserver
- /etc/localtime:/etc/localtime:ro
environment:
- ENABLE_SPAMASSASSIN=0
- ENABLE_CLAMAV=0
- ENABLE_FAIL2BAN=0
- ENABLE_POSTGREY=0
- ONE_DIR=1
- DMS_DEBUG=0
- PERMIT_DOCKER=network
- SSL_TYPE=
cap_add:
- NET_ADMIN
restart: unless-stopped

View file

@ -12,16 +12,16 @@ toml = "1.0"
sha2 = "0.10"
num-bigint = "0.4"
base64 = "0.22"
rand = "0.8"
rand = { version = "0.8.5", features = ["getrandom"] }
pwhash = "0.3" # bcrypt with caller-supplied salt (used for SRP)
bcrypt = "0.15" # reference bcrypt impl for key passphrase derivation
pgp = { version = "0.14", default-features = false } # rpgp — OpenPGP decrypt
bcrypt = "0.16" # reference bcrypt impl for key passphrase derivation
pgp = { version = "0.15", default-features = false } # rpgp — OpenPGP decrypt
chrono = "0.4"
env_logger = "0.11"
aes = "0.8"
cfb-mode = "0.8"
sha1 = "0.10"
tracing = "0.1.44"
rcgen = "0.13"
tokio-rustls = "0.26"
rustls = "0.23"
[target.'cfg(target_os = "macos")'.dependencies]
keyring = { version = "3", features = ["apple-native"] }

View file

@ -5,9 +5,10 @@
/// Each accepted connection is handled in its own tokio task.
use std::sync::Arc;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::{TcpListener, TcpStream};
use tracing::{debug, error, info, warn};
use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
use tokio::net::TcpListener;
use tokio_rustls::TlsAcceptor;
use tracing::{debug, error, warn};
use crate::api::{ApiClient, LABEL_INBOX};
use crate::{crypto, SharedState};
@ -18,12 +19,20 @@ use crate::{crypto, SharedState};
pub async fn run_with_listener(
state: SharedState,
listener: TcpListener,
acceptor: TlsAcceptor,
) -> Result<(), Box<dyn std::error::Error>> {
loop {
let (socket, _addr) = listener.accept().await?;
let tls_stream = match acceptor.accept(socket).await {
Ok(s) => s,
Err(e) => {
error!("IMAP TLS handshake failed: {e}");
continue;
}
};
let state = Arc::clone(&state);
tokio::spawn(async move {
if let Err(e) = handle_connection(socket, state).await {
if let Err(e) = handle_connection(tls_stream, state).await {
error!("IMAP connection error: {e}");
}
});
@ -32,12 +41,15 @@ pub async fn run_with_listener(
// ── Per-connection handler ────────────────────────────────────────────────────
async fn handle_connection(
socket: TcpStream,
async fn handle_connection<S>(
socket: S,
state: SharedState,
) -> Result<(), Box<dyn std::error::Error>> {
let (reader, mut writer) = socket.into_split();
let mut lines = BufReader::new(reader).lines();
) -> Result<(), Box<dyn std::error::Error>>
where
S: AsyncRead + AsyncWrite + Unpin + Send,
{
let (rd, mut writer) = tokio::io::split(socket);
let mut lines = BufReader::new(rd).lines();
let mut authenticated = false;
writer

View file

@ -33,14 +33,15 @@ pub type SharedState = Arc<Mutex<BridgeState>>;
// ── Public entry point ────────────────────────────────────────────────────────
/// Authenticate with ProtonMail, bind IMAP and SMTP ports, then return `Ok(())`.
/// Authenticate with ProtonMail, bind IMAP and SMTP ports, then return the
/// self-signed TLS certificate (DER bytes) used for local bridge communication.
/// The servers keep running in background Tokio tasks on a dedicated OS thread.
/// Returns `Err` if authentication or port binding fails.
/// Returns `Err` if authentication, cert generation, or port binding fails.
///
/// This is the integration entry point for tuimail. It is synchronous so the
/// caller (tuimail's main thread) needs no Tokio runtime of its own.
pub fn start(config: config::Config) -> Result<(), String> {
let (ready_tx, ready_rx) = std::sync::mpsc::channel::<Result<(), String>>();
pub fn start(config: config::Config) -> Result<Vec<u8>, String> {
let (ready_tx, ready_rx) = std::sync::mpsc::channel::<Result<Vec<u8>, String>>();
std::thread::spawn(move || {
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
@ -48,8 +49,8 @@ pub fn start(config: config::Config) -> Result<(), String> {
.expect("tokio runtime");
rt.block_on(async move {
match run(config).await {
Ok(()) => {
let _ = ready_tx.send(Ok(()));
Ok(cert_der) => {
let _ = ready_tx.send(Ok(cert_der));
// Keep the runtime alive so spawned server tasks keep running.
std::future::pending::<()>().await;
}
@ -66,7 +67,7 @@ pub fn start(config: config::Config) -> Result<(), String> {
// ── Async startup ─────────────────────────────────────────────────────────────
async fn run(config: config::Config) -> Result<(), String> {
async fn run(config: config::Config) -> Result<Vec<u8>, String> {
let client = auth::build_client().map_err(|e| format!("HTTP client: {e}"))?;
let session = auth::authenticate(&client, &config.proton)
.await
@ -88,6 +89,32 @@ async fn run(config: config::Config) -> Result<(), String> {
store.load_all(messages);
info!("Inbox: {} messages ({total} total)", store.count());
// Generate a self-signed TLS cert for local bridge communication.
// Use an IP SAN for 127.0.0.1 so TLS hostname validation works when
// connecting by IP address (which is what native_tls/lettre require).
let key_pair = rcgen::KeyPair::generate()
.map_err(|e| format!("key gen: {e}"))?;
let mut params = rcgen::CertificateParams::new(Vec::<String>::new())
.map_err(|e| format!("cert params: {e}"))?;
params.subject_alt_names.push(rcgen::SanType::IpAddress(
std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST),
));
let cert = params
.self_signed(&key_pair)
.map_err(|e| format!("cert gen: {e}"))?;
let cert_der: Vec<u8> = cert.der().to_vec();
let key_der: Vec<u8> = key_pair.serialize_der();
use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer};
let server_cfg = rustls::ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(
vec![CertificateDer::from(cert_der.clone())],
PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key_der)),
)
.map_err(|e| format!("rustls config: {e}"))?;
let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(server_cfg));
// Bind ports before spawning tasks — ports are occupied when start() returns.
let imap_listener = TcpListener::bind(("127.0.0.1", config.bridge.imap_port))
.await
@ -108,18 +135,19 @@ async fn run(config: config::Config) -> Result<(), String> {
}));
let imap_state = Arc::clone(&state);
let imap_acceptor = acceptor.clone();
tokio::spawn(async move {
if let Err(e) = imap_server::run_with_listener(imap_state, imap_listener).await {
if let Err(e) = imap_server::run_with_listener(imap_state, imap_listener, imap_acceptor).await {
eprintln!("IMAP server error: {e}");
}
});
tokio::spawn(async move {
if let Err(e) = smtp_server::run_with_listener(state, smtp_listener).await {
if let Err(e) = smtp_server::run_with_listener(state, smtp_listener, acceptor).await {
error!("SMTP server error: {e}");
}
});
Ok(()) // ← ports are bound; start() unblocks here
Ok(cert_der) // ← ports are bound; start() unblocks here
}
// ── Key pool setup ────────────────────────────────────────────────────────────

View file

@ -5,13 +5,14 @@
/// the ProtonMail v4 API (create draft → send).
use std::sync::Arc;
use base64::engine::general_purpose::STANDARD as B64;
use base64::Engine;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::{TcpListener, TcpStream};
use tracing::{error, info};
use crate::api::ApiClient;
use crate::{crypto, SharedState};
use crate::{SharedState, crypto};
use base64::Engine;
use base64::engine::general_purpose::STANDARD as B64;
use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
use tokio::net::TcpListener;
use tokio_rustls::TlsAcceptor;
use tracing::error;
// ── Public entry point ────────────────────────────────────────────────────────
@ -20,12 +21,20 @@ use crate::{crypto, SharedState};
pub async fn run_with_listener(
state: SharedState,
listener: TcpListener,
acceptor: TlsAcceptor,
) -> Result<(), Box<dyn std::error::Error>> {
loop {
let (socket, _addr) = listener.accept().await?;
let tls_stream = match acceptor.accept(socket).await {
Ok(s) => s,
Err(e) => {
error!("SMTP TLS handshake failed: {e}");
continue;
}
};
let state = Arc::clone(&state);
tokio::spawn(async move {
if let Err(e) = handle_connection(socket, state).await {
if let Err(e) = handle_connection(tls_stream, state).await {
error!("SMTP connection error: {e}");
}
});
@ -34,12 +43,15 @@ pub async fn run_with_listener(
// ── Per-connection handler ────────────────────────────────────────────────────
async fn handle_connection(
socket: TcpStream,
async fn handle_connection<S>(
socket: S,
state: SharedState,
) -> Result<(), Box<dyn std::error::Error>> {
let (reader, mut writer) = socket.into_split();
let mut lines = BufReader::new(reader).lines();
) -> Result<(), Box<dyn std::error::Error>>
where
S: AsyncRead + AsyncWrite + Unpin + Send,
{
let (rd, mut writer) = tokio::io::split(socket);
let mut lines = BufReader::new(rd).lines();
let mut authenticated = false;
let mut mail_from = String::new();
@ -49,7 +61,9 @@ async fn handle_connection(
// auth_step: 0 = not in AUTH, 1 = sent "Username:", 2 = sent "Password:"
let mut auth_step: u8 = 0;
writer.write_all(b"220 ProtonBridge ESMTP ready\r\n").await?;
writer
.write_all(b"220 ProtonBridge ESMTP ready\r\n")
.await?;
while let Some(raw) = lines.next_line().await? {
let line = raw.trim_end().to_string();
@ -61,8 +75,7 @@ async fn handle_connection(
if in_data {
if line == "." {
in_data = false;
let resp =
handle_send(&mail_from, &rcpt_to, &data_lines, &state).await;
let resp = handle_send(&mail_from, &rcpt_to, &data_lines, &state).await;
writer.write_all(resp.as_bytes()).await?;
mail_from.clear();
rcpt_to.clear();
@ -198,9 +211,7 @@ async fn handle_send(
let plaintext = data_lines[blank + 1..].join("\n");
for recipient in to {
if let Err(e) =
send_to_one(from, recipient, &subject, &plaintext, state).await
{
if let Err(e) = send_to_one(from, recipient, &subject, &plaintext, state).await {
// Log the full error in the bridge console; return a short fixed
// message to skim so the status line doesn't overflow.
eprintln!("SMTP send to {recipient} FAILED:\n {e}");
@ -264,7 +275,13 @@ async fn send_to_one(
.map_err(|e| format!("encrypt draft body: {e}"))?,
};
(st.http_client.clone(), st.session.clone(), aid, db, signing_key)
(
st.http_client.clone(),
st.session.clone(),
aid,
db,
signing_key,
)
};
let api = ApiClient::new(&http_client, &session);
@ -283,7 +300,8 @@ async fn send_to_one(
// message body arrives clean (no PGP armor visible in Gmail / other clients).
// ProtonMail decrypts the SEIPD using BodyKey and relays the multipart/signed
// MIME entity to the external recipient over SMTP.
let signing_key = signing_key_opt.as_ref()
let signing_key = signing_key_opt
.as_ref()
.ok_or("no signing key available for external send")?;
let (seipd, session_key) = crypto::build_pgp_mime_for_external_send(plaintext, signing_key)
.map_err(|e| format!("PGP/MIME external body: {e}"))?;
@ -298,7 +316,14 @@ async fn send_to_one(
.create_draft(&address_id, subject, from, "", to, "", &draft_body)
.await?;
api.send_draft(&draft_id, to, send_type, &body_b64, &key_packet_b64, body_signed)
api.send_draft(
&draft_id,
to,
send_type,
&body_b64,
&key_packet_b64,
body_signed,
)
.await?;
Ok(())
}

View file

@ -46,6 +46,8 @@ pub struct SmtpConfig {
pub password: Option<String>,
pub tls_mode: TlsMode,
pub from: String,
#[serde(skip)]
pub tls_cert_der: Option<Vec<u8>>,
}
#[derive(Debug, Deserialize, Clone)]
@ -56,6 +58,8 @@ pub struct ImapConfig {
#[serde(default)]
pub password: Option<String>,
pub use_tls: bool,
#[serde(skip)]
pub tls_cert_der: Option<Vec<u8>>,
}
/// Mirror of `proton_bridge::config::ProtonConfig` — used for config parsing
@ -97,34 +101,37 @@ impl Config {
/// Fill in password fields from the OS keychain (with env-var fallback).
/// Must be called after `load()` and before any provider operations.
/// Accesses the keychain exactly once regardless of how many credentials
/// are needed, so macOS only shows one approval prompt per binary rebuild.
pub fn inject_credentials(&mut self) -> Result<(), String> {
use crate::credentials;
use crate::credentials::{self, CredentialStore};
let store = CredentialStore::load();
match self.provider {
Provider::Imap => {
if let Some(ref mut imap) = self.imap
&& imap.password.is_none()
{
imap.password = Some(credentials::get(credentials::IMAP_PASSWORD)?);
imap.password = Some(store.require(credentials::IMAP_PASSWORD)?);
}
if let Some(ref mut smtp) = self.smtp && smtp.password.is_none(){
smtp.password = Some(credentials::get(credentials::SMTP_PASSWORD)?);
if let Some(ref mut smtp) = self.smtp && smtp.password.is_none() {
smtp.password = Some(store.require(credentials::SMTP_PASSWORD)?);
}
}
Provider::Proton => {
if let Some(ref mut proton) = self.proton {
if proton.password.is_none() {
proton.password = Some(credentials::get(credentials::PROTON_PASSWORD)?);
proton.password = Some(store.require(credentials::PROTON_PASSWORD)?);
}
if proton.mailbox_password.is_none() {
proton.mailbox_password =
credentials::get(credentials::PROTON_MAILBOX_PASSWORD).ok();
store.require(credentials::PROTON_MAILBOX_PASSWORD).ok();
}
}
if let Some(ref mut bridge) = self.bridge
&& bridge.local_password.is_none()
{
bridge.local_password =
Some(credentials::get(credentials::BRIDGE_LOCAL_PASSWORD)?);
Some(store.require(credentials::BRIDGE_LOCAL_PASSWORD)?);
}
}
}
@ -150,7 +157,8 @@ impl Config {
port: b.imap_port,
username: "bridge".into(),
password: b.local_password.clone(),
use_tls: false,
use_tls: true,
tls_cert_der: None, // injected by main.rs after start()
})
}
}
@ -178,8 +186,9 @@ impl Config {
port: b.smtp_port,
username: "bridge".into(),
password: b.local_password.clone(),
tls_mode: TlsMode::None,
tls_mode: TlsMode::Smtps,
from,
tls_cert_der: None, // injected by main.rs after start()
})
}
}
@ -187,7 +196,6 @@ impl Config {
/// Convert to the `proton-bridge` crate's `Config` type.
/// Only valid when `provider = "proton"`.
#[cfg(feature = "proton")]
pub fn as_bridge_config(&self) -> Result<proton_bridge::config::Config, String> {
let p = self.proton.as_ref().ok_or("[proton] section missing")?;
let b = self.bridge.as_ref().ok_or("[bridge] section missing")?;

View file

@ -42,9 +42,13 @@ pub(crate) fn connect(config: &Config) -> Result<ImapSession, String> {
tcp.set_write_timeout(Some(IO_TIMEOUT)).map_err(|e| e.to_string())?;
if imap_cfg.use_tls {
let tls = native_tls::TlsConnector::builder()
.build()
let mut builder = native_tls::TlsConnector::builder();
if let Some(ref der) = imap_cfg.tls_cert_der {
let cert = native_tls::Certificate::from_der(der)
.map_err(|e| e.to_string())?;
builder.add_root_certificate(cert);
}
let tls = builder.build().map_err(|e| e.to_string())?;
let tls_stream = tls.connect(&imap_cfg.host, tcp)
.map_err(|e| e.to_string())?;
let password = imap_cfg.password.as_deref().unwrap_or("");

View file

@ -1,3 +1,5 @@
use std::collections::HashMap;
pub const IMAP_PASSWORD: &str = "imap_password";
pub const SMTP_PASSWORD: &str = "smtp_password";
pub const PROTON_PASSWORD: &str = "proton_password";
@ -5,35 +7,93 @@ pub const PROTON_MAILBOX_PASSWORD: &str = "proton_mailbox_password";
pub const BRIDGE_LOCAL_PASSWORD: &str = "bridge_local_password";
const SERVICE: &str = "tuimail";
/// All credentials live in one keychain entry as a JSON map.
/// One entry = one macOS approval prompt per binary rebuild.
const BUNDLE_KEY: &str = "credentials";
pub fn get(key: &str) -> Result<String, String> {
// 1. OS keychain
let keychain_err = match keyring::Entry::new(SERVICE, key) {
Ok(entry) => match entry.get_password() {
Ok(val) => return Ok(val),
Err(e) => format!("{e}"),
},
Err(e) => format!("entry creation failed: {e}"),
};
// 2. env var: TUIMAIL_<KEY_UPPERCASE>
/// Holds all credentials in memory. Load once with `CredentialStore::load()`,
/// mutate, then flush with `save()`. This keeps keychain accesses to a minimum.
pub struct CredentialStore {
bundle: HashMap<String, String>,
}
impl CredentialStore {
/// Load the credential bundle from the keychain. One keychain access.
pub fn load() -> Self {
Self { bundle: load_bundle() }
}
pub fn contains(&self, key: &str) -> bool {
self.bundle.contains_key(key)
}
/// Return a stored value (in-memory; no keychain access).
pub fn get(&self, key: &str) -> Option<&str> {
self.bundle.get(key).map(String::as_str)
}
/// Return a stored value, falling back to env `TUIMAIL_<KEY>`.
pub fn require(&self, key: &str) -> Result<String, String> {
if let Some(val) = self.bundle.get(key) {
return Ok(val.clone());
}
let env_key = format!("TUIMAIL_{}", key.to_uppercase());
std::env::var(&env_key).map_err(|_| {
format!(
"Credential '{key}' not found (keychain: {keychain_err}). \
"Credential '{key}' not found. \
Run with --configure to set up credentials, or set {env_key}."
)
})
}
}
pub fn set(key: &str, value: &str) -> Result<(), String> {
keyring::Entry::new(SERVICE, key)
/// Update a value in memory (no keychain write until `save()`).
pub fn set(&mut self, key: &str, value: &str) {
self.bundle.insert(key.to_string(), value.to_string());
}
/// Remove a value in memory (no keychain write until `save()`).
pub fn delete(&mut self, key: &str) {
self.bundle.remove(key);
}
/// Persist the bundle to the keychain. One keychain write.
pub fn save(&self) -> Result<(), String> {
let json = serde_json::to_string(&self.bundle).map_err(|e| e.to_string())?;
keyring::Entry::new(SERVICE, BUNDLE_KEY)
.map_err(|e| e.to_string())?
.set_password(value)
.set_password(&json)
.map_err(|e| e.to_string())
}
pub fn delete(key: &str) {
if let Ok(entry) = keyring::Entry::new(SERVICE, key) {
let _ = entry.delete_credential();
}
}
fn load_bundle() -> HashMap<String, String> {
let Ok(entry) = keyring::Entry::new(SERVICE, BUNDLE_KEY) else {
return HashMap::new();
};
let Ok(json) = entry.get_password() else {
return HashMap::new();
};
serde_json::from_str(&json).unwrap_or_default()
}
// ── Convenience wrappers (single-key operations) ─────────────────────────────
/// Get a single credential. Prefer `CredentialStore::load()` when fetching
/// multiple credentials so the keychain is accessed only once.
pub fn get(key: &str) -> Result<String, String> {
CredentialStore::load().require(key)
}
/// Set a single credential (read-modify-write on the bundle).
pub fn set(key: &str, value: &str) -> Result<(), String> {
let mut store = CredentialStore::load();
store.set(key, value);
store.save()
}
/// Delete a single credential (read-modify-write on the bundle).
pub fn delete(key: &str) {
let mut store = CredentialStore::load();
store.delete(key);
let _ = store.save();
}

View file

@ -3,13 +3,10 @@ use std::process::exit;
use crossterm::{
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::{Terminal, backend::CrosstermBackend};
use tuimail::config::{Config, Provider};
use ratatui::{
backend::CrosstermBackend,
Terminal,
};
fn main() -> io::Result<()> {
// ── Parse --configure flag ──
@ -49,38 +46,32 @@ fn main() -> io::Result<()> {
exit(1);
});
// ── Derive effective IMAP/SMTP configs for the chosen provider ──
let mut imap_cfg = config.effective_imap().unwrap_or_else(|e| {
eprintln!("{e}");
exit(1);
});
let mut smtp_cfg = config.effective_smtp().unwrap_or_else(|e| {
eprintln!("{e}");
exit(1);
});
// ── Start bridge if needed (before entering raw mode so output is clean) ──
if config.provider == Provider::Proton {
#[cfg(feature = "proton")]
{
let bridge_cfg = config.as_bridge_config().unwrap_or_else(|e| {
eprintln!("Bridge config error: {e}");
exit(1);
});
eprint!("Starting ProtonMail bridge...");
proton_bridge::start(bridge_cfg).unwrap_or_else(|e| {
let cert_der = proton_bridge::start(bridge_cfg).unwrap_or_else(|e| {
eprintln!("\nBridge failed to start: {e}");
exit(1);
});
eprintln!(" ready.");
}
#[cfg(not(feature = "proton"))]
{
eprintln!("tuimail was not compiled with ProtonMail support.");
eprintln!("Rebuild with: cargo build --features proton");
exit(1);
}
imap_cfg.tls_cert_der = Some(cert_der.clone());
smtp_cfg.tls_cert_der = Some(cert_der);
}
// ── Derive effective IMAP/SMTP configs for the chosen provider ──
let imap_cfg = config.effective_imap().unwrap_or_else(|e| {
eprintln!("{e}");
exit(1);
});
let smtp_cfg = config.effective_smtp().unwrap_or_else(|e| {
eprintln!("{e}");
exit(1);
});
let runtime_config = Config {
imap: Some(imap_cfg),
smtp: Some(smtp_cfg),

View file

@ -2,7 +2,7 @@ use std::io::{self, BufRead, Write};
use std::path::PathBuf;
use crate::config::{BridgeConfig, Config, ImapConfig, Provider, ProtonConfig, SmtpConfig, TlsMode};
use crate::credentials;
use crate::credentials::{self, CredentialStore};
// ── helpers ──────────────────────────────────────────────────────────────────
@ -184,6 +184,9 @@ fn setup_imap(existing: Option<&Config>) -> Result<Config, String> {
let ex_imap = existing.and_then(|c| c.imap.as_ref());
let ex_smtp = existing.and_then(|c| c.smtp.as_ref());
// Load once; all existence checks and updates happen in memory.
let mut store = CredentialStore::load();
println!("--- IMAP settings ---");
let imap_host = prompt("IMAP host", ex_imap.map(|i| i.host.as_str()));
let imap_port_default = ex_imap.map(|i| i.port.to_string());
@ -193,13 +196,13 @@ fn setup_imap(existing: Option<&Config>) -> Result<Config, String> {
let imap_user = prompt("IMAP username (email)", ex_imap.map(|i| i.username.as_str()));
let imap_tls = prompt_bool("Use TLS for IMAP?", ex_imap.map(|i| i.use_tls).unwrap_or(true));
let imap_pass = if credentials::get(credentials::IMAP_PASSWORD).is_ok() {
let imap_pass = if store.contains(credentials::IMAP_PASSWORD) {
prompt_password_optional("IMAP password")
} else {
Some(prompt_password_required("IMAP password"))
};
if let Some(ref pw) = imap_pass {
credentials::set(credentials::IMAP_PASSWORD, pw)?;
store.set(credentials::IMAP_PASSWORD, pw);
}
println!("\n--- SMTP settings ---");
@ -212,15 +215,18 @@ fn setup_imap(existing: Option<&Config>) -> Result<Config, String> {
let smtp_tls = prompt_tls_mode(ex_smtp.map(|s| &s.tls_mode));
let smtp_from = prompt("From address (e.g. Name <addr@example.com>)", ex_smtp.map(|s| s.from.as_str()));
let smtp_pass = if credentials::get(credentials::SMTP_PASSWORD).is_ok() {
let smtp_pass = if store.contains(credentials::SMTP_PASSWORD) {
prompt_password_optional("SMTP password")
} else {
Some(prompt_password_required("SMTP password"))
};
if let Some(ref pw) = smtp_pass {
credentials::set(credentials::SMTP_PASSWORD, pw)?;
store.set(credentials::SMTP_PASSWORD, pw);
}
// One write for all credentials.
store.save()?;
Ok(Config {
provider: Provider::Imap,
imap: Some(ImapConfig {
@ -229,6 +235,7 @@ fn setup_imap(existing: Option<&Config>) -> Result<Config, String> {
username: imap_user,
password: None, // stored in keychain
use_tls: imap_tls,
tls_cert_der: None,
}),
smtp: Some(SmtpConfig {
host: smtp_host,
@ -237,6 +244,7 @@ fn setup_imap(existing: Option<&Config>) -> Result<Config, String> {
password: None, // stored in keychain
tls_mode: smtp_tls,
from: smtp_from,
tls_cert_der: None,
}),
proton: None,
bridge: None,
@ -247,16 +255,19 @@ fn setup_proton(existing: Option<&Config>) -> Result<Config, String> {
let ex_proton = existing.and_then(|c| c.proton.as_ref());
let ex_bridge = existing.and_then(|c| c.bridge.as_ref());
// Load once; all existence checks and updates happen in memory.
let mut store = CredentialStore::load();
println!("--- ProtonMail settings ---");
let username = prompt("Proton account email", ex_proton.map(|p| p.username.as_str()));
let proton_pass = if credentials::get(credentials::PROTON_PASSWORD).is_ok() {
let proton_pass = if store.contains(credentials::PROTON_PASSWORD) {
prompt_password_optional("Proton login password")
} else {
Some(prompt_password_required("Proton login password"))
};
if let Some(ref pw) = proton_pass {
credentials::set(credentials::PROTON_PASSWORD, pw)?;
store.set(credentials::PROTON_PASSWORD, pw);
}
let two_pw = prompt_bool(
@ -264,16 +275,16 @@ fn setup_proton(existing: Option<&Config>) -> Result<Config, String> {
ex_proton.and_then(|p| p.mailbox_password.as_ref()).is_some(),
);
if two_pw {
let mbx_pass = if credentials::get(credentials::PROTON_MAILBOX_PASSWORD).is_ok() {
let mbx_pass = if store.contains(credentials::PROTON_MAILBOX_PASSWORD) {
prompt_password_optional("Mailbox password")
} else {
Some(prompt_password_required("Mailbox password"))
};
if let Some(ref pw) = mbx_pass {
credentials::set(credentials::PROTON_MAILBOX_PASSWORD, pw)?;
store.set(credentials::PROTON_MAILBOX_PASSWORD, pw);
}
} else {
credentials::delete(credentials::PROTON_MAILBOX_PASSWORD);
store.delete(credentials::PROTON_MAILBOX_PASSWORD);
}
let bridge_imap_default = ex_bridge.map(|b| b.imap_port.to_string());
@ -292,13 +303,14 @@ fn setup_proton(existing: Option<&Config>) -> Result<Config, String> {
.unwrap_or(1025);
// Generate a new local bridge password only if one isn't already stored.
let local_pw_exists = credentials::get(credentials::BRIDGE_LOCAL_PASSWORD).is_ok();
if !local_pw_exists {
let local_pw = random_hex(16);
credentials::set(credentials::BRIDGE_LOCAL_PASSWORD, &local_pw)?;
if !store.contains(credentials::BRIDGE_LOCAL_PASSWORD) {
store.set(credentials::BRIDGE_LOCAL_PASSWORD, &random_hex(16));
println!("Generated bridge local password (stored in keychain).");
}
// One write for all credentials.
store.save()?;
Ok(Config {
provider: Provider::Proton,
imap: None,

View file

@ -2,7 +2,7 @@ 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::transport::smtp::client::{Certificate, Tls, TlsParameters};
use lettre::{Message, SmtpTransport, Transport};
use crate::config::{SmtpConfig, TlsMode};
@ -39,7 +39,15 @@ pub(crate) fn send_email(
}
TlsMode::Smtps => {
// SMTPS: TLS from the first byte (port 465)
let tls = TlsParameters::new(cfg.host.clone()).map_err(|e| e.to_string())?;
let tls = if let Some(ref der) = cfg.tls_cert_der {
let cert = Certificate::from_der(der.clone()).map_err(|e| e.to_string())?;
TlsParameters::builder(cfg.host.clone())
.add_root_certificate(cert)
.build_native()
.map_err(|e| e.to_string())?
} else {
TlsParameters::new(cfg.host.clone()).map_err(|e| e.to_string())?
};
SmtpTransport::relay(&cfg.host)
.map_err(|e| e.to_string())?
.port(cfg.port)