Compare commits
10 commits
3b131fefe8
...
11941a5bea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11941a5bea | ||
|
|
696c3bec9f | ||
|
|
a2d6e72b7a | ||
|
|
5a3ac2eed9 | ||
|
|
9375b5b96f | ||
|
|
b64d1411d2 | ||
|
|
56b27e1692 | ||
|
|
ef5127e0de | ||
|
|
1eb4affdb6 | ||
|
|
e2badaa170 |
18 changed files with 636 additions and 855 deletions
113
CLAUDE.md
113
CLAUDE.md
|
|
@ -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
282
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
10
Cargo.toml
10
Cargo.toml
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
83
PROTON.md
83
PROTON.md
|
|
@ -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
184
README.md
Normal 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
249
USAGE.md
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)?);
|
||||
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")?;
|
||||
|
|
|
|||
|
|
@ -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("");
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
.map_err(|e| e.to_string())?
|
||||
.set_password(value)
|
||||
.map_err(|e| e.to_string())
|
||||
/// 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(&json)
|
||||
.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) {
|
||||
if let Ok(entry) = keyring::Entry::new(SERVICE, key) {
|
||||
let _ = entry.delete_credential();
|
||||
}
|
||||
let mut store = CredentialStore::load();
|
||||
store.delete(key);
|
||||
let _ = store.save();
|
||||
}
|
||||
|
|
|
|||
39
src/main.rs
39
src/main.rs
|
|
@ -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),
|
||||
|
|
|
|||
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;
|
||||
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,
|
||||
|
|
|
|||
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::{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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue