Compare commits
No commits in common. "11941a5beaecf6fd612c2addcdbcdfccf1d7d2bd" and "3b131fefe8d7f929f4de5e468e863a31bb518677" have entirely different histories.
11941a5bea
...
3b131fefe8
18 changed files with 852 additions and 633 deletions
113
CLAUDE.md
Normal file
113
CLAUDE.md
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
# 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
282
Cargo.lock
generated
|
|
@ -336,28 +336,6 @@ 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"
|
||||
|
|
@ -384,9 +362,9 @@ checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
|||
|
||||
[[package]]
|
||||
name = "bcrypt"
|
||||
version = "0.16.0"
|
||||
version = "0.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b1866ecef4f2d06a0bb77880015fdf2b89e25a1c2e5addacb87e459c86dc67e"
|
||||
checksum = "e65938ed058ef47d92cf8b346cc76ef48984572ade631927e9937b5ffc7662c7"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"blowfish 0.9.1",
|
||||
|
|
@ -421,9 +399,9 @@ checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb"
|
|||
|
||||
[[package]]
|
||||
name = "bitfield"
|
||||
version = "0.17.0"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f798d2d157e547aa99aab0967df39edd0b70307312b6f8bd2848e6abe40896e0"
|
||||
checksum = "2d7e60934ceec538daadb9d8432424ed043a904d8e0243f3c6446bce549a46ac"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
|
|
@ -609,8 +587,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"jobserver",
|
||||
"libc",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
|
|
@ -700,15 +676,6 @@ 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"
|
||||
|
|
@ -921,7 +888,7 @@ dependencies = [
|
|||
"cpufeatures",
|
||||
"curve25519-dalek-derive",
|
||||
"digest 0.10.7",
|
||||
"fiat-crypto",
|
||||
"fiat-crypto 0.2.9",
|
||||
"rustc_version",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
|
|
@ -1189,23 +1156,23 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "dirs"
|
||||
version = "6.0.0"
|
||||
version = "5.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
|
||||
checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
|
||||
dependencies = [
|
||||
"dirs-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs-sys"
|
||||
version = "0.5.0"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
|
||||
checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1259,12 +1226,6 @@ 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"
|
||||
|
|
@ -1316,6 +1277,17 @@ 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"
|
||||
|
|
@ -1505,6 +1477,12 @@ 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"
|
||||
|
|
@ -1592,12 +1570,6 @@ 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"
|
||||
|
|
@ -2233,16 +2205,6 @@ 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"
|
||||
|
|
@ -2490,9 +2452,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "mailparse"
|
||||
version = "0.16.1"
|
||||
version = "0.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "60819a97ddcb831a5614eb3b0174f3620e793e97e09195a395bfa948fd68ed2f"
|
||||
checksum = "3da03d5980411a724e8aaf7b61a7b5e386ec55a7fb49ee3d0ff79efc7e5e7c7e"
|
||||
dependencies = [
|
||||
"charset",
|
||||
"data-encoding",
|
||||
|
|
@ -2994,16 +2956,6 @@ 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"
|
||||
|
|
@ -3064,9 +3016,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "pgp"
|
||||
version = "0.15.0"
|
||||
version = "0.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "30249ac8a98b356b473b04bc5358c75a260aa96a295d0743ce752fe7b173f235"
|
||||
checksum = "1877a97fd422433220ad272eb008ec55691944b1200e9eb204e3cb2cb69d34e9"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
|
|
@ -3122,9 +3074,10 @@ dependencies = [
|
|||
"sha3",
|
||||
"signature",
|
||||
"smallvec",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror 1.0.69",
|
||||
"twofish",
|
||||
"x25519-dalek",
|
||||
"x448",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
|
|
@ -3418,8 +3371,10 @@ dependencies = [
|
|||
name = "proton-bridge"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"base64 0.22.1",
|
||||
"bcrypt",
|
||||
"cfb-mode",
|
||||
"chrono",
|
||||
"env_logger",
|
||||
"keyring",
|
||||
|
|
@ -3427,14 +3382,12 @@ dependencies = [
|
|||
"pgp",
|
||||
"pwhash",
|
||||
"rand 0.8.5",
|
||||
"rcgen",
|
||||
"reqwest",
|
||||
"rustls",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha1",
|
||||
"sha2 0.10.9",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"toml",
|
||||
"tracing",
|
||||
]
|
||||
|
|
@ -3669,19 +3622,6 @@ 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"
|
||||
|
|
@ -3693,13 +3633,13 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "redox_users"
|
||||
version = "0.5.2"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
|
||||
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
"libredox",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -3783,20 +3723,6 @@ 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"
|
||||
|
|
@ -3883,21 +3809,6 @@ 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"
|
||||
|
|
@ -3907,18 +3818,6 @@ 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"
|
||||
|
|
@ -4622,16 +4521,6 @@ 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"
|
||||
|
|
@ -4810,7 +4699,6 @@ dependencies = [
|
|||
"ratatui",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"toml",
|
||||
"tui-markdown",
|
||||
]
|
||||
|
|
@ -4898,12 +4786,6 @@ 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"
|
||||
|
|
@ -5275,6 +5157,15 @@ 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"
|
||||
|
|
@ -5311,6 +5202,21 @@ 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"
|
||||
|
|
@ -5344,6 +5250,12 @@ 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"
|
||||
|
|
@ -5356,6 +5268,12 @@ 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"
|
||||
|
|
@ -5368,6 +5286,12 @@ 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"
|
||||
|
|
@ -5392,6 +5316,12 @@ 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"
|
||||
|
|
@ -5404,6 +5334,12 @@ 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"
|
||||
|
|
@ -5416,6 +5352,12 @@ 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"
|
||||
|
|
@ -5428,6 +5370,12 @@ 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"
|
||||
|
|
@ -5555,6 +5503,17 @@ 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"
|
||||
|
|
@ -5580,15 +5539,6 @@ 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"
|
||||
|
|
|
|||
10
Cargo.toml
10
Cargo.toml
|
|
@ -6,8 +6,11 @@ name = "tuimail"
|
|||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[features]
|
||||
proton = ["dep:proton-bridge"]
|
||||
|
||||
[dependencies]
|
||||
proton-bridge = { path = "proton-bridge", optional = false }
|
||||
proton-bridge = { path = "proton-bridge", optional = true }
|
||||
ratatui = "0.30"
|
||||
crossterm = "0.29"
|
||||
imap = "2.4"
|
||||
|
|
@ -15,16 +18,15 @@ native-tls = "0.2"
|
|||
serde = { version = "1.0", features = ["derive"] }
|
||||
toml = "1.0"
|
||||
chrono = "0.4"
|
||||
mailparse = "0.16"
|
||||
mailparse = "0.15"
|
||||
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 = "6.0"
|
||||
dirs = "5"
|
||||
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"] }
|
||||
|
|
|
|||
124
MAIL_SERVER_SETUP.md
Normal file
124
MAIL_SERVER_SETUP.md
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
# 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
|
||||
83
PROTON.md
Normal file
83
PROTON.md
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
# 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
184
README.md
|
|
@ -1,184 +0,0 @@
|
|||
# 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
Normal file
249
USAGE.md
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
# 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.
|
||||
30
docker-compose.yml
Normal file
30
docker-compose.yml
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
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
|
||||
|
|
@ -12,16 +12,16 @@ toml = "1.0"
|
|||
sha2 = "0.10"
|
||||
num-bigint = "0.4"
|
||||
base64 = "0.22"
|
||||
rand = { version = "0.8.5", features = ["getrandom"] }
|
||||
rand = "0.8"
|
||||
pwhash = "0.3" # bcrypt with caller-supplied salt (used for SRP)
|
||||
bcrypt = "0.16" # reference bcrypt impl for key passphrase derivation
|
||||
pgp = { version = "0.15", default-features = false } # rpgp — OpenPGP decrypt
|
||||
bcrypt = "0.15" # reference bcrypt impl for key passphrase derivation
|
||||
pgp = { version = "0.14", 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"] }
|
||||
|
|
|
|||
|
|
@ -5,10 +5,9 @@
|
|||
/// Each accepted connection is handled in its own tokio task.
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio_rustls::TlsAcceptor;
|
||||
use tracing::{debug, error, warn};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
use tracing::{debug, error, info, warn};
|
||||
use crate::api::{ApiClient, LABEL_INBOX};
|
||||
use crate::{crypto, SharedState};
|
||||
|
||||
|
|
@ -19,20 +18,12 @@ 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(tls_stream, state).await {
|
||||
if let Err(e) = handle_connection(socket, state).await {
|
||||
error!("IMAP connection error: {e}");
|
||||
}
|
||||
});
|
||||
|
|
@ -41,15 +32,12 @@ pub async fn run_with_listener(
|
|||
|
||||
// ── Per-connection handler ────────────────────────────────────────────────────
|
||||
|
||||
async fn handle_connection<S>(
|
||||
socket: S,
|
||||
async fn handle_connection(
|
||||
socket: TcpStream,
|
||||
state: SharedState,
|
||||
) -> 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();
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let (reader, mut writer) = socket.into_split();
|
||||
let mut lines = BufReader::new(reader).lines();
|
||||
let mut authenticated = false;
|
||||
|
||||
writer
|
||||
|
|
|
|||
|
|
@ -33,15 +33,14 @@ pub type SharedState = Arc<Mutex<BridgeState>>;
|
|||
|
||||
// ── Public entry point ────────────────────────────────────────────────────────
|
||||
|
||||
/// Authenticate with ProtonMail, bind IMAP and SMTP ports, then return the
|
||||
/// self-signed TLS certificate (DER bytes) used for local bridge communication.
|
||||
/// Authenticate with ProtonMail, bind IMAP and SMTP ports, then return `Ok(())`.
|
||||
/// The servers keep running in background Tokio tasks on a dedicated OS thread.
|
||||
/// Returns `Err` if authentication, cert generation, or port binding fails.
|
||||
/// Returns `Err` if authentication 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<Vec<u8>, String> {
|
||||
let (ready_tx, ready_rx) = std::sync::mpsc::channel::<Result<Vec<u8>, String>>();
|
||||
pub fn start(config: config::Config) -> Result<(), String> {
|
||||
let (ready_tx, ready_rx) = std::sync::mpsc::channel::<Result<(), String>>();
|
||||
std::thread::spawn(move || {
|
||||
let rt = tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
|
|
@ -49,8 +48,8 @@ pub fn start(config: config::Config) -> Result<Vec<u8>, String> {
|
|||
.expect("tokio runtime");
|
||||
rt.block_on(async move {
|
||||
match run(config).await {
|
||||
Ok(cert_der) => {
|
||||
let _ = ready_tx.send(Ok(cert_der));
|
||||
Ok(()) => {
|
||||
let _ = ready_tx.send(Ok(()));
|
||||
// Keep the runtime alive so spawned server tasks keep running.
|
||||
std::future::pending::<()>().await;
|
||||
}
|
||||
|
|
@ -67,7 +66,7 @@ pub fn start(config: config::Config) -> Result<Vec<u8>, String> {
|
|||
|
||||
// ── Async startup ─────────────────────────────────────────────────────────────
|
||||
|
||||
async fn run(config: config::Config) -> Result<Vec<u8>, String> {
|
||||
async fn run(config: config::Config) -> Result<(), String> {
|
||||
let client = auth::build_client().map_err(|e| format!("HTTP client: {e}"))?;
|
||||
let session = auth::authenticate(&client, &config.proton)
|
||||
.await
|
||||
|
|
@ -89,32 +88,6 @@ async fn run(config: config::Config) -> Result<Vec<u8>, 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
|
||||
|
|
@ -135,19 +108,18 @@ async fn run(config: config::Config) -> Result<Vec<u8>, 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, imap_acceptor).await {
|
||||
if let Err(e) = imap_server::run_with_listener(imap_state, imap_listener).await {
|
||||
eprintln!("IMAP server error: {e}");
|
||||
}
|
||||
});
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = smtp_server::run_with_listener(state, smtp_listener, acceptor).await {
|
||||
if let Err(e) = smtp_server::run_with_listener(state, smtp_listener).await {
|
||||
error!("SMTP server error: {e}");
|
||||
}
|
||||
});
|
||||
|
||||
Ok(cert_der) // ← ports are bound; start() unblocks here
|
||||
Ok(()) // ← ports are bound; start() unblocks here
|
||||
}
|
||||
|
||||
// ── Key pool setup ────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -5,14 +5,13 @@
|
|||
/// the ProtonMail v4 API (create draft → send).
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::api::ApiClient;
|
||||
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;
|
||||
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};
|
||||
|
||||
// ── Public entry point ────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -21,20 +20,12 @@ use tracing::error;
|
|||
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(tls_stream, state).await {
|
||||
if let Err(e) = handle_connection(socket, state).await {
|
||||
error!("SMTP connection error: {e}");
|
||||
}
|
||||
});
|
||||
|
|
@ -43,15 +34,12 @@ pub async fn run_with_listener(
|
|||
|
||||
// ── Per-connection handler ────────────────────────────────────────────────────
|
||||
|
||||
async fn handle_connection<S>(
|
||||
socket: S,
|
||||
async fn handle_connection(
|
||||
socket: TcpStream,
|
||||
state: SharedState,
|
||||
) -> 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();
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let (reader, mut writer) = socket.into_split();
|
||||
let mut lines = BufReader::new(reader).lines();
|
||||
|
||||
let mut authenticated = false;
|
||||
let mut mail_from = String::new();
|
||||
|
|
@ -61,9 +49,7 @@ where
|
|||
// 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();
|
||||
|
|
@ -75,7 +61,8 @@ where
|
|||
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();
|
||||
|
|
@ -211,7 +198,9 @@ 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}");
|
||||
|
|
@ -275,13 +264,7 @@ 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);
|
||||
|
||||
|
|
@ -300,8 +283,7 @@ 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}"))?;
|
||||
|
|
@ -316,14 +298,7 @@ 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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,8 +46,6 @@ 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)]
|
||||
|
|
@ -58,8 +56,6 @@ 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
|
||||
|
|
@ -101,37 +97,34 @@ 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::{self, CredentialStore};
|
||||
let store = CredentialStore::load();
|
||||
use crate::credentials;
|
||||
match self.provider {
|
||||
Provider::Imap => {
|
||||
if let Some(ref mut imap) = self.imap
|
||||
&& imap.password.is_none()
|
||||
{
|
||||
imap.password = Some(store.require(credentials::IMAP_PASSWORD)?);
|
||||
imap.password = Some(credentials::get(credentials::IMAP_PASSWORD)?);
|
||||
}
|
||||
if let Some(ref mut smtp) = self.smtp && smtp.password.is_none(){
|
||||
smtp.password = Some(store.require(credentials::SMTP_PASSWORD)?);
|
||||
smtp.password = Some(credentials::get(credentials::SMTP_PASSWORD)?);
|
||||
}
|
||||
}
|
||||
Provider::Proton => {
|
||||
if let Some(ref mut proton) = self.proton {
|
||||
if proton.password.is_none() {
|
||||
proton.password = Some(store.require(credentials::PROTON_PASSWORD)?);
|
||||
proton.password = Some(credentials::get(credentials::PROTON_PASSWORD)?);
|
||||
}
|
||||
if proton.mailbox_password.is_none() {
|
||||
proton.mailbox_password =
|
||||
store.require(credentials::PROTON_MAILBOX_PASSWORD).ok();
|
||||
credentials::get(credentials::PROTON_MAILBOX_PASSWORD).ok();
|
||||
}
|
||||
}
|
||||
if let Some(ref mut bridge) = self.bridge
|
||||
&& bridge.local_password.is_none()
|
||||
{
|
||||
bridge.local_password =
|
||||
Some(store.require(credentials::BRIDGE_LOCAL_PASSWORD)?);
|
||||
Some(credentials::get(credentials::BRIDGE_LOCAL_PASSWORD)?);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -157,8 +150,7 @@ impl Config {
|
|||
port: b.imap_port,
|
||||
username: "bridge".into(),
|
||||
password: b.local_password.clone(),
|
||||
use_tls: true,
|
||||
tls_cert_der: None, // injected by main.rs after start()
|
||||
use_tls: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -186,9 +178,8 @@ impl Config {
|
|||
port: b.smtp_port,
|
||||
username: "bridge".into(),
|
||||
password: b.local_password.clone(),
|
||||
tls_mode: TlsMode::Smtps,
|
||||
tls_mode: TlsMode::None,
|
||||
from,
|
||||
tls_cert_der: None, // injected by main.rs after start()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -196,6 +187,7 @@ 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")?;
|
||||
|
|
|
|||
|
|
@ -42,13 +42,9 @@ 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 mut builder = native_tls::TlsConnector::builder();
|
||||
if let Some(ref der) = imap_cfg.tls_cert_der {
|
||||
let cert = native_tls::Certificate::from_der(der)
|
||||
let tls = native_tls::TlsConnector::builder()
|
||||
.build()
|
||||
.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("");
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
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";
|
||||
|
|
@ -7,93 +5,35 @@ 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";
|
||||
|
||||
/// 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());
|
||||
}
|
||||
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>
|
||||
let env_key = format!("TUIMAIL_{}", key.to_uppercase());
|
||||
std::env::var(&env_key).map_err(|_| {
|
||||
format!(
|
||||
"Credential '{key}' not found. \
|
||||
"Credential '{key}' not found (keychain: {keychain_err}). \
|
||||
Run with --configure to set up credentials, or set {env_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)
|
||||
pub fn set(key: &str, value: &str) -> Result<(), String> {
|
||||
keyring::Entry::new(SERVICE, key)
|
||||
.map_err(|e| e.to_string())?
|
||||
.set_password(&json)
|
||||
.set_password(value)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
if let Ok(entry) = keyring::Entry::new(SERVICE, key) {
|
||||
let _ = entry.delete_credential();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
39
src/main.rs
39
src/main.rs
|
|
@ -3,10 +3,13 @@ use std::process::exit;
|
|||
|
||||
use crossterm::{
|
||||
execute,
|
||||
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
|
||||
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||
};
|
||||
use ratatui::{Terminal, backend::CrosstermBackend};
|
||||
use tuimail::config::{Config, Provider};
|
||||
use ratatui::{
|
||||
backend::CrosstermBackend,
|
||||
Terminal,
|
||||
};
|
||||
|
||||
fn main() -> io::Result<()> {
|
||||
// ── Parse --configure flag ──
|
||||
|
|
@ -46,32 +49,38 @@ 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...");
|
||||
let cert_der = proton_bridge::start(bridge_cfg).unwrap_or_else(|e| {
|
||||
proton_bridge::start(bridge_cfg).unwrap_or_else(|e| {
|
||||
eprintln!("\nBridge failed to start: {e}");
|
||||
exit(1);
|
||||
});
|
||||
eprintln!(" ready.");
|
||||
imap_cfg.tls_cert_der = Some(cert_der.clone());
|
||||
smtp_cfg.tls_cert_der = Some(cert_der);
|
||||
}
|
||||
#[cfg(not(feature = "proton"))]
|
||||
{
|
||||
eprintln!("tuimail was not compiled with ProtonMail support.");
|
||||
eprintln!("Rebuild with: cargo build --features proton");
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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),
|
||||
|
|
|
|||
40
src/setup.rs
40
src/setup.rs
|
|
@ -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::{self, CredentialStore};
|
||||
use crate::credentials;
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -184,9 +184,6 @@ 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());
|
||||
|
|
@ -196,13 +193,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 store.contains(credentials::IMAP_PASSWORD) {
|
||||
let imap_pass = if credentials::get(credentials::IMAP_PASSWORD).is_ok() {
|
||||
prompt_password_optional("IMAP password")
|
||||
} else {
|
||||
Some(prompt_password_required("IMAP password"))
|
||||
};
|
||||
if let Some(ref pw) = imap_pass {
|
||||
store.set(credentials::IMAP_PASSWORD, pw);
|
||||
credentials::set(credentials::IMAP_PASSWORD, pw)?;
|
||||
}
|
||||
|
||||
println!("\n--- SMTP settings ---");
|
||||
|
|
@ -215,18 +212,15 @@ 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 store.contains(credentials::SMTP_PASSWORD) {
|
||||
let smtp_pass = if credentials::get(credentials::SMTP_PASSWORD).is_ok() {
|
||||
prompt_password_optional("SMTP password")
|
||||
} else {
|
||||
Some(prompt_password_required("SMTP password"))
|
||||
};
|
||||
if let Some(ref pw) = smtp_pass {
|
||||
store.set(credentials::SMTP_PASSWORD, pw);
|
||||
credentials::set(credentials::SMTP_PASSWORD, pw)?;
|
||||
}
|
||||
|
||||
// One write for all credentials.
|
||||
store.save()?;
|
||||
|
||||
Ok(Config {
|
||||
provider: Provider::Imap,
|
||||
imap: Some(ImapConfig {
|
||||
|
|
@ -235,7 +229,6 @@ 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,
|
||||
|
|
@ -244,7 +237,6 @@ 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,
|
||||
|
|
@ -255,19 +247,16 @@ 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 store.contains(credentials::PROTON_PASSWORD) {
|
||||
let proton_pass = if credentials::get(credentials::PROTON_PASSWORD).is_ok() {
|
||||
prompt_password_optional("Proton login password")
|
||||
} else {
|
||||
Some(prompt_password_required("Proton login password"))
|
||||
};
|
||||
if let Some(ref pw) = proton_pass {
|
||||
store.set(credentials::PROTON_PASSWORD, pw);
|
||||
credentials::set(credentials::PROTON_PASSWORD, pw)?;
|
||||
}
|
||||
|
||||
let two_pw = prompt_bool(
|
||||
|
|
@ -275,16 +264,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 store.contains(credentials::PROTON_MAILBOX_PASSWORD) {
|
||||
let mbx_pass = if credentials::get(credentials::PROTON_MAILBOX_PASSWORD).is_ok() {
|
||||
prompt_password_optional("Mailbox password")
|
||||
} else {
|
||||
Some(prompt_password_required("Mailbox password"))
|
||||
};
|
||||
if let Some(ref pw) = mbx_pass {
|
||||
store.set(credentials::PROTON_MAILBOX_PASSWORD, pw);
|
||||
credentials::set(credentials::PROTON_MAILBOX_PASSWORD, pw)?;
|
||||
}
|
||||
} else {
|
||||
store.delete(credentials::PROTON_MAILBOX_PASSWORD);
|
||||
credentials::delete(credentials::PROTON_MAILBOX_PASSWORD);
|
||||
}
|
||||
|
||||
let bridge_imap_default = ex_bridge.map(|b| b.imap_port.to_string());
|
||||
|
|
@ -303,14 +292,13 @@ 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.
|
||||
if !store.contains(credentials::BRIDGE_LOCAL_PASSWORD) {
|
||||
store.set(credentials::BRIDGE_LOCAL_PASSWORD, &random_hex(16));
|
||||
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)?;
|
||||
println!("Generated bridge local password (stored in keychain).");
|
||||
}
|
||||
|
||||
// One write for all credentials.
|
||||
store.save()?;
|
||||
|
||||
Ok(Config {
|
||||
provider: Provider::Proton,
|
||||
imap: None,
|
||||
|
|
|
|||
12
src/smtp.rs
12
src/smtp.rs
|
|
@ -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::{Certificate, Tls, TlsParameters};
|
||||
use lettre::transport::smtp::client::{Tls, TlsParameters};
|
||||
use lettre::{Message, SmtpTransport, Transport};
|
||||
use crate::config::{SmtpConfig, TlsMode};
|
||||
|
||||
|
|
@ -39,15 +39,7 @@ pub(crate) fn send_email(
|
|||
}
|
||||
TlsMode::Smtps => {
|
||||
// SMTPS: TLS from the first byte (port 465)
|
||||
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())?
|
||||
};
|
||||
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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue