From facb44d5615bf273668e6e3bd4b17d68d52fb3ba Mon Sep 17 00:00:00 2001 From: Shautvast Date: Wed, 25 Feb 2026 10:50:23 +0100 Subject: [PATCH] Store credentials in OS keychain via keyring crate Passwords are no longer stored in config.toml. Instead: - New setup wizard (--configure) prompts for credentials on first run and stores them in the OS keychain (macOS Keychain, GNOME Keyring / KWallet on Linux, Windows Credential Manager) - Env-var fallback: TUIMAIL_ for headless environments - ProtonMail session token moves from session.json to the keychain - Config file path moves to {config_dir}/tuimail/config.toml Co-Authored-By: Claude Sonnet 4.6 --- Cargo.lock | 673 +++++++++++++++++++++++++++++++++++++- Cargo.toml | 13 +- USAGE.md | 79 +++-- config.toml.example | 16 +- proton-bridge/Cargo.toml | 11 +- proton-bridge/src/auth.rs | 18 +- src/config.rs | 75 ++++- src/connect.rs | 6 +- src/credentials.rs | 39 +++ src/lib.rs | 2 + src/main.rs | 36 +- src/setup.rs | 314 ++++++++++++++++++ src/smtp.rs | 2 +- 13 files changed, 1219 insertions(+), 65 deletions(-) create mode 100644 src/credentials.rs create mode 100644 src/setup.rs diff --git a/Cargo.lock b/Cargo.lock index eff78a1..20c991d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -185,6 +185,123 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b" +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.116", +] + +[[package]] +name = "async-signal" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.116", +] + [[package]] name = "atomic" version = "0.6.1" @@ -343,6 +460,19 @@ dependencies = [ "generic-array", ] +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "blowfish" version = "0.5.0" @@ -441,6 +571,15 @@ dependencies = [ "rustversion", ] +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.2.56" @@ -557,6 +696,15 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -572,6 +720,16 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -612,6 +770,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "crossterm" version = "0.29.0" @@ -825,6 +989,35 @@ dependencies = [ "generic-array", ] +[[package]] +name = "dbus" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b3aa68d7e7abee336255bd7248ea965cc393f3e70411135a6f6a4b651345d4" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "dbus-secret-service" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" +dependencies = [ + "aes", + "block-padding", + "cbc", + "dbus", + "fastrand", + "hkdf", + "num", + "once_cell", + "sha2 0.10.9", + "zeroize", +] + [[package]] name = "deltae" version = "0.3.2" @@ -961,6 +1154,27 @@ dependencies = [ "subtle", ] +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -1126,6 +1340,33 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.116", +] + [[package]] name = "env_filter" version = "1.0.0" @@ -1174,6 +1415,27 @@ dependencies = [ "num-traits", ] +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fancy-regex" version = "0.11.0" @@ -1323,6 +1585,25 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.32" @@ -1334,6 +1615,12 @@ dependencies = [ "syn 2.0.116", ] +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + [[package]] name = "futures-task" version = "0.3.32" @@ -1354,6 +1641,7 @@ checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", "futures-macro", + "futures-sink", "futures-task", "pin-project-lite", "slab", @@ -1489,6 +1777,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" @@ -1827,6 +2121,7 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ + "block-padding", "generic-array", ] @@ -1954,6 +2249,23 @@ dependencies = [ "cpufeatures", ] +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "byteorder", + "dbus-secret-service", + "linux-keyutils", + "log", + "secret-service", + "security-framework 2.11.1", + "security-framework 3.6.0", + "windows-sys 0.60.2", + "zeroize", +] + [[package]] name = "lab" version = "0.11.0" @@ -2017,12 +2329,31 @@ version = "0.2.182" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + [[package]] name = "libm" version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags 2.11.0", + "libc", +] + [[package]] name = "line-clipping" version = "0.3.5" @@ -2038,6 +2369,16 @@ version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" +[[package]] +name = "linux-keyutils" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "761e49ec5fd8a5a463f9b84e877c373d888935b71c6be78f3767fe2ae6bed18e" +dependencies = [ + "bitflags 2.11.0", + "libc", +] + [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -2208,7 +2549,7 @@ dependencies = [ "openssl-probe", "openssl-sys", "schannel", - "security-framework", + "security-framework 3.6.0", "security-framework-sys", "tempfile", ] @@ -2262,6 +2603,20 @@ dependencies = [ "memchr", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -2289,6 +2644,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.0" @@ -2326,6 +2690,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2478,6 +2853,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "ordered-float" version = "4.6.0" @@ -2487,6 +2868,16 @@ dependencies = [ "num-traits", ] +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "p256" version = "0.13.2" @@ -2525,6 +2916,12 @@ dependencies = [ "sha2 0.10.9", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -2801,6 +3198,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "piper" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkcs1" version = "0.7.5" @@ -2841,6 +3249,20 @@ dependencies = [ "time", ] +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "polyval" version = "0.6.2" @@ -2955,6 +3377,7 @@ dependencies = [ "cfb-mode", "chrono", "env_logger", + "keyring", "num-bigint", "pgp", "pwhash", @@ -3208,6 +3631,17 @@ dependencies = [ "bitflags 2.11.0", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "regex" version = "1.12.3" @@ -3434,6 +3868,38 @@ dependencies = [ "zeroize", ] +[[package]] +name = "secret-service" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4" +dependencies = [ + "aes", + "cbc", + "futures-util", + "generic-array", + "hkdf", + "num", + "once_cell", + "rand 0.8.5", + "serde", + "sha2 0.10.9", + "zbus", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + [[package]] name = "security-framework" version = "3.6.0" @@ -3441,7 +3907,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d17b898a6d6948c3a8ee4372c17cb384f90d2e6e912ef00895b14fd7ab54ec38" dependencies = [ "bitflags 2.11.0", - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -3525,6 +3991,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.116", +] + [[package]] name = "serde_spanned" version = "1.0.4" @@ -4208,13 +4685,16 @@ version = "0.1.0" dependencies = [ "chrono", "crossterm", + "dirs", "fast_html2md", "imap", + "keyring", "lettre", "mailparse", "native-tls", "proton-bridge", "quoted_printable", + "rand 0.8.5", "ratatui", "regex", "serde", @@ -4243,6 +4723,17 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" +[[package]] +name = "uds_windows" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9" +dependencies = [ + "memoffset", + "tempfile", + "winapi", +] + [[package]] name = "unicase" version = "2.9.0" @@ -4665,6 +5156,24 @@ 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -4692,6 +5201,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" @@ -4725,6 +5249,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" @@ -4737,6 +5267,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" @@ -4749,6 +5285,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" @@ -4773,6 +5315,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" @@ -4785,6 +5333,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" @@ -4797,6 +5351,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" @@ -4809,6 +5369,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" @@ -4947,6 +5513,16 @@ dependencies = [ "rand_core 0.5.1", ] +[[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "yaml-rust" version = "0.4.5" @@ -4985,6 +5561,62 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" +dependencies = [ + "async-broadcast", + "async-process", + "async-recursion", + "async-trait", + "enumflags2", + "event-listener", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix", + "ordered-stream", + "rand 0.8.5", + "serde", + "serde_repr", + "sha1", + "static_assertions", + "tracing", + "uds_windows", + "windows-sys 0.52.0", + "xdg-home", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.116", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" +dependencies = [ + "serde", + "static_assertions", + "zvariant", +] + [[package]] name = "zerocopy" version = "0.8.39" @@ -5084,3 +5716,40 @@ name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zvariant" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" +dependencies = [ + "endi", + "enumflags2", + "serde", + "static_assertions", + "zvariant_derive", +] + +[[package]] +name = "zvariant_derive" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.116", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.116", +] diff --git a/Cargo.toml b/Cargo.toml index 54cb928..623e7e0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,4 +23,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"] } \ No newline at end of file +lettre = { version = "0.11", default-features = false, features = ["smtp-transport", "native-tls", "builder"] } +dirs = "5" +rand = { version = "0.8", features = ["getrandom"] } + +[target.'cfg(target_os = "macos")'.dependencies] +keyring = { version = "3", features = ["apple-native"] } + +[target.'cfg(target_os = "linux")'.dependencies] +keyring = { version = "3", features = ["linux-native-sync-persistent", "crypto-rust"] } + +[target.'cfg(target_os = "windows")'.dependencies] +keyring = { version = "3", features = ["windows-native"] } \ No newline at end of file diff --git a/USAGE.md b/USAGE.md index 855215b..fd3c5c3 100644 --- a/USAGE.md +++ b/USAGE.md @@ -7,29 +7,48 @@ the email list on top, the message preview on the bottom. ## Setup -Copy the example config and fill in your credentials: +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 -cp config.toml.example config.toml +cargo run ``` -Edit `config.toml`: +The wizard prompts for your provider, server settings, and passwords, then +saves the config file and stores all passwords in the OS keychain. -```toml -[imap] -host = "imap.gmail.com" # your provider's IMAP server -port = 993 -username = "you@example.com" -password = "your-password" -use_tls = true +### Re-configure / update credentials -[smtp] -host = "smtp.gmail.com" # your provider's SMTP server -port = 465 -username = "you@example.com" -password = "your-password" -tls_mode = "smtps" # none | starttls | smtps -from = "Your Name " +```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** @@ -56,22 +75,16 @@ The bridge starts automatically in-process when `provider = "proton"` is set. cargo build --features proton ``` -**2. Configure `config.toml`** (remove or comment out `[imap]` / `[smtp]`): +**2. Run the setup wizard:** -```toml -provider = "proton" - -[proton] -username = "you@proton.me" -password = "your-proton-login-password" -# mailbox_password = "..." # only for two-password-mode accounts - -[bridge] -imap_port = 1143 -smtp_port = 1025 -local_password = "changeme" # any string; used only locally +```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 @@ -219,9 +232,10 @@ progress. Your current selection is preserved across refreshes. | `host` | string | IMAP server hostname | | `port` | integer | IMAP port (usually 993 with TLS, 143 without) | | `username` | string | Login username (usually your full email address) | -| `password` | string | Password or app-specific password | | `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 | @@ -229,6 +243,7 @@ progress. Your current selection is preserved across refreshes. | `host` | string | SMTP server hostname | | `port` | integer | SMTP port | | `username` | string | Login username | -| `password` | string | Password or app-specific password | | `tls_mode` | string | `none`, `starttls`, or `smtps` | | `from` | string | Sender address shown to recipients, e.g. `Name ` | + +> Password is stored in the OS keychain. Use `--configure` to set or update it. diff --git a/config.toml.example b/config.toml.example index c1a77e6..c1e9e22 100644 --- a/config.toml.example +++ b/config.toml.example @@ -1,3 +1,10 @@ +# Passwords are stored securely in the OS keychain. +# Run `tuimail --configure` to set up credentials. +# +# This file shows all non-sensitive fields you can set manually. +# Copy it to the tuimail config directory and edit as needed, then +# run `tuimail --configure` to store passwords in the keychain. + # ── Standard IMAP/SMTP provider (default) ───────────────────────────────────── # provider = "imap" # optional — "imap" is the default @@ -5,14 +12,12 @@ host = "imap.gmail.com" # your provider's IMAP server port = 993 username = "you@example.com" -password = "your-app-password" use_tls = true [smtp] host = "smtp.gmail.com" # your provider's SMTP server port = 465 username = "you@example.com" -password = "your-app-password" # tls_mode options: none | starttls | smtps tls_mode = "smtps" from = "Your Name " @@ -27,10 +32,7 @@ from = "Your Name " # # [proton] # username = "you@proton.me" -# password = "your-proton-login-password" -# # mailbox_password = "..." # only for two-password-mode accounts # # [bridge] -# imap_port = 1143 -# smtp_port = 1025 -# local_password = "changeme" # any string; used only locally between tuimail and the bridge +# imap_port = 1143 +# smtp_port = 1025 diff --git a/proton-bridge/Cargo.toml b/proton-bridge/Cargo.toml index 5485b52..c357c9e 100644 --- a/proton-bridge/Cargo.toml +++ b/proton-bridge/Cargo.toml @@ -21,4 +21,13 @@ env_logger = "0.11" aes = "0.8" cfb-mode = "0.8" sha1 = "0.10" -tracing = "0.1.44" \ No newline at end of file +tracing = "0.1.44" + +[target.'cfg(target_os = "macos")'.dependencies] +keyring = { version = "3", features = ["apple-native"] } + +[target.'cfg(target_os = "linux")'.dependencies] +keyring = { version = "3", features = ["linux-native-sync-persistent", "crypto-rust"] } + +[target.'cfg(target_os = "windows")'.dependencies] +keyring = { version = "3", features = ["windows-native"] } \ No newline at end of file diff --git a/proton-bridge/src/auth.rs b/proton-bridge/src/auth.rs index ad6c971..17fb73a 100644 --- a/proton-bridge/src/auth.rs +++ b/proton-bridge/src/auth.rs @@ -1,6 +1,5 @@ use reqwest::Client; use serde::{Deserialize, Serialize}; -use std::fs; use std::io::{self, Write}; use std::time::{SystemTime, UNIX_EPOCH}; use tracing::{error, info, warn}; @@ -8,7 +7,8 @@ use crate::config::ProtonConfig; use crate::srp; const API_BASE: &str = "https://mail.proton.me/api"; -const SESSION_FILE: &str = "session.json"; +const SESSION_KEY: &str = "proton_session"; +const SERVICE: &str = "tuimail"; /// Refresh 5 minutes before the token actually expires. const REFRESH_MARGIN_SECS: u64 = 300; @@ -128,17 +128,23 @@ impl Session { } pub fn save(&self) -> Result<(), String> { - let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?; - fs::write(SESSION_FILE, json).map_err(|e| e.to_string()) + let json = serde_json::to_string(self).map_err(|e| e.to_string())?; + keyring::Entry::new(SERVICE, SESSION_KEY) + .map_err(|e| e.to_string())? + .set_password(&json) + .map_err(|e| e.to_string()) } pub fn load() -> Option { - let json = fs::read_to_string(SESSION_FILE).ok()?; + let entry = keyring::Entry::new(SERVICE, SESSION_KEY).ok()?; + let json = entry.get_password().ok()?; serde_json::from_str(&json).ok() } pub fn delete() { - let _ = fs::remove_file(SESSION_FILE); + if let Ok(entry) = keyring::Entry::new(SERVICE, SESSION_KEY) { + let _ = entry.delete_credential(); + } } } diff --git a/src/config.rs b/src/config.rs index 6ba8d6d..0155550 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,5 +1,6 @@ use serde::Deserialize; use std::fs; +use std::path::PathBuf; #[derive(Debug, Deserialize, Clone, Default, PartialEq)] #[serde(rename_all = "lowercase")] @@ -41,7 +42,8 @@ pub struct SmtpConfig { pub host: String, pub port: u16, pub username: String, - pub password: String, + #[serde(default)] + pub password: Option, pub tls_mode: TlsMode, pub from: String, } @@ -51,7 +53,8 @@ pub struct ImapConfig { pub host: String, pub port: u16, pub username: String, - pub password: String, + #[serde(default)] + pub password: Option, pub use_tls: bool, } @@ -60,7 +63,8 @@ pub struct ImapConfig { #[derive(Debug, Deserialize, Clone)] pub struct ProtonConfig { pub username: String, - pub password: String, + #[serde(default)] + pub password: Option, pub mailbox_password: Option, pub user_key_passphrase: Option, } @@ -70,16 +74,65 @@ pub struct ProtonConfig { pub struct BridgeConfig { pub imap_port: u16, pub smtp_port: u16, - pub local_password: String, + #[serde(default)] + pub local_password: Option, } impl Config { + /// Return the path to the config file: `{config_dir}/tuimail/config.toml`. + pub fn config_path() -> PathBuf { + dirs::config_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("tuimail") + .join("config.toml") + } + pub fn load() -> Result> { - let content = fs::read_to_string("config.toml")?; + let path = Self::config_path(); + let content = fs::read_to_string(&path) + .map_err(|e| format!("Cannot read {}: {e}", path.display()))?; let config: Config = toml::from_str(&content)?; Ok(config) } + /// Fill in password fields from the OS keychain (with env-var fallback). + /// Must be called after `load()` and before any provider operations. + pub fn inject_credentials(&mut self) -> Result<(), String> { + use crate::credentials; + match self.provider { + Provider::Imap => { + if let Some(ref mut imap) = self.imap { + if imap.password.is_none() { + imap.password = Some(credentials::get(credentials::IMAP_PASSWORD)?); + } + } + if let Some(ref mut smtp) = self.smtp { + if smtp.password.is_none() { + 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(credentials::get(credentials::PROTON_PASSWORD)?); + } + if proton.mailbox_password.is_none() { + proton.mailbox_password = + credentials::get(credentials::PROTON_MAILBOX_PASSWORD).ok(); + } + } + if let Some(ref mut bridge) = self.bridge { + if bridge.local_password.is_none() { + bridge.local_password = + Some(credentials::get(credentials::BRIDGE_LOCAL_PASSWORD)?); + } + } + } + } + Ok(()) + } + /// Returns the effective IMAP config regardless of provider. /// For `provider = "imap"` this is the `[imap]` section. /// For `provider = "proton"` this is derived from `[bridge]`. @@ -88,12 +141,12 @@ impl Config { Provider::Imap => self .imap .clone() - .ok_or_else(|| "[imap] section missing from config.toml".to_string()), + .ok_or_else(|| "[imap] section missing from config".to_string()), Provider::Proton => { let b = self .bridge .as_ref() - .ok_or_else(|| "[bridge] section missing from config.toml".to_string())?; + .ok_or_else(|| "[bridge] section missing from config".to_string())?; Ok(ImapConfig { host: "127.0.0.1".into(), port: b.imap_port, @@ -111,12 +164,12 @@ impl Config { Provider::Imap => self .smtp .clone() - .ok_or_else(|| "[smtp] section missing from config.toml".to_string()), + .ok_or_else(|| "[smtp] section missing from config".to_string()), Provider::Proton => { let b = self .bridge .as_ref() - .ok_or_else(|| "[bridge] section missing from config.toml".to_string())?; + .ok_or_else(|| "[bridge] section missing from config".to_string())?; let from = self .proton .as_ref() @@ -143,14 +196,14 @@ impl Config { Ok(proton_bridge::config::Config { proton: proton_bridge::config::ProtonConfig { username: p.username.clone(), - password: p.password.clone(), + password: p.password.clone().unwrap_or_default(), mailbox_password: p.mailbox_password.clone(), user_key_passphrase: p.user_key_passphrase.clone(), }, bridge: proton_bridge::config::BridgeConfig { imap_port: b.imap_port, smtp_port: b.smtp_port, - local_password: b.local_password.clone(), + local_password: b.local_password.clone().unwrap_or_default(), }, }) } diff --git a/src/connect.rs b/src/connect.rs index ee489b4..f7e2151 100644 --- a/src/connect.rs +++ b/src/connect.rs @@ -47,13 +47,15 @@ pub(crate) fn connect(config: &Config) -> Result { .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(""); let session = imap::Client::new(tls_stream) - .login(&imap_cfg.username, &imap_cfg.password) + .login(&imap_cfg.username, password) .map_err(|(e, _)| e.to_string())?; Ok(ImapSession::Tls(session)) } else { + let password = imap_cfg.password.as_deref().unwrap_or(""); let session = imap::Client::new(tcp) - .login(&imap_cfg.username, &imap_cfg.password) + .login(&imap_cfg.username, password) .map_err(|(e, _)| e.to_string())?; Ok(ImapSession::Plain(session)) } diff --git a/src/credentials.rs b/src/credentials.rs new file mode 100644 index 0000000..133079c --- /dev/null +++ b/src/credentials.rs @@ -0,0 +1,39 @@ +pub const IMAP_PASSWORD: &str = "imap_password"; +pub const SMTP_PASSWORD: &str = "smtp_password"; +pub const PROTON_PASSWORD: &str = "proton_password"; +pub const PROTON_MAILBOX_PASSWORD: &str = "proton_mailbox_password"; +pub const BRIDGE_LOCAL_PASSWORD: &str = "bridge_local_password"; + +const SERVICE: &str = "tuimail"; + +pub fn get(key: &str) -> Result { + // 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_ + let env_key = format!("TUIMAIL_{}", key.to_uppercase()); + std::env::var(&env_key).map_err(|_| { + format!( + "Credential '{key}' not found (keychain: {keychain_err}). \ + 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()) +} + +pub fn delete(key: &str) { + if let Ok(entry) = keyring::Entry::new(SERVICE, key) { + let _ = entry.delete_credential(); + } +} diff --git a/src/lib.rs b/src/lib.rs index 123c514..940f961 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,6 +13,8 @@ use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph}; use crate::config::Config; pub mod config; +pub mod credentials; +pub mod setup; mod connect; mod inbox; mod smtp; diff --git a/src/main.rs b/src/main.rs index 6e6bbe1..44f226d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,8 +12,40 @@ use ratatui::{ }; fn main() -> io::Result<()> { - let config = Config::load().unwrap_or_else(|e| { - eprintln!("Failed to load config.toml: {e}"); + // ── Parse --configure flag ── + let reconfigure = std::env::args().any(|a| a == "--configure"); + + // ── First-time setup or reconfigure (before raw mode so stdin/stdout work) ── + let mut config = if !Config::config_path().exists() || reconfigure { + if reconfigure && Config::config_path().exists() { + let cfg = Config::load().unwrap_or_else(|e| { + eprintln!("Failed to load config: {e}"); + exit(1); + }); + tuimail::setup::run_configure(&cfg).unwrap_or_else(|e| { + eprintln!("Configuration failed: {e}"); + exit(1); + }); + Config::load().unwrap_or_else(|e| { + eprintln!("Failed to reload config: {e}"); + exit(1); + }) + } else { + tuimail::setup::run_first_time_setup().unwrap_or_else(|e| { + eprintln!("Setup failed: {e}"); + exit(1); + }) + } + } else { + Config::load().unwrap_or_else(|e| { + eprintln!("Failed to load config: {e}"); + exit(1); + }) + }; + + // ── Inject credentials from keychain / env vars ── + config.inject_credentials().unwrap_or_else(|e| { + eprintln!("{e}"); exit(1); }); diff --git a/src/setup.rs b/src/setup.rs new file mode 100644 index 0000000..0835c71 --- /dev/null +++ b/src/setup.rs @@ -0,0 +1,314 @@ +use std::io::{self, BufRead, Write}; +use std::path::PathBuf; + +use crate::config::{BridgeConfig, Config, ImapConfig, Provider, ProtonConfig, SmtpConfig, TlsMode}; +use crate::credentials; + +// ── helpers ────────────────────────────────────────────────────────────────── + +fn prompt(label: &str, default: Option<&str>) -> String { + let hint = default.map(|d| format!(" [{d}]")).unwrap_or_default(); + print!("{label}{hint}: "); + io::stdout().flush().unwrap(); + let mut line = String::new(); + io::stdin().lock().read_line(&mut line).unwrap(); + let trimmed = line.trim().to_string(); + if trimmed.is_empty() { + default.unwrap_or("").to_string() + } else { + trimmed + } +} + +/// Optional password prompt — returns None (keep existing) when Enter is pressed with no input. +/// Use for --configure where a stored value may already exist. +fn prompt_password_optional(label: &str) -> Option { + print!("{label} [stored, Enter to keep]: "); + io::stdout().flush().unwrap(); + let mut line = String::new(); + io::stdin().lock().read_line(&mut line).unwrap(); + let trimmed = line.trim().to_string(); + if trimmed.is_empty() { None } else { Some(trimmed) } +} + +/// Required password prompt — keeps asking until the user types something. +fn prompt_password_required(label: &str) -> String { + loop { + print!("{label}: "); + io::stdout().flush().unwrap(); + let mut line = String::new(); + io::stdin().lock().read_line(&mut line).unwrap(); + let trimmed = line.trim().to_string(); + if !trimmed.is_empty() { + return trimmed; + } + println!("Password cannot be empty."); + } +} + +fn prompt_bool(label: &str, default: bool) -> bool { + let hint = if default { "Y/n" } else { "y/N" }; + print!("{label} [{hint}]: "); + io::stdout().flush().unwrap(); + let mut line = String::new(); + io::stdin().lock().read_line(&mut line).unwrap(); + match line.trim().to_lowercase().as_str() { + "y" | "yes" => true, + "n" | "no" => false, + _ => default, + } +} + +fn prompt_tls_mode(default: Option<&TlsMode>) -> TlsMode { + let default_str = default.map(|m| match m { + TlsMode::None => "none", + TlsMode::Starttls => "starttls", + TlsMode::Smtps => "smtps", + }); + loop { + let val = prompt("SMTP TLS mode (none/starttls/smtps)", default_str); + match val.to_lowercase().as_str() { + "none" => return TlsMode::None, + "starttls" => return TlsMode::Starttls, + "smtps" => return TlsMode::Smtps, + _ => println!("Please enter: none, starttls, or smtps"), + } + } +} + +fn random_hex(bytes: usize) -> String { + use rand::RngCore; + let mut buf = vec![0u8; bytes]; + rand::thread_rng().fill_bytes(&mut buf); + buf.iter().map(|b| format!("{b:02x}")).collect() +} + +fn write_config(config: &Config) -> Result<(), String> { + let path = Config::config_path(); + let fallback = PathBuf::from("."); + let dir = path.parent().unwrap_or(&fallback); + std::fs::create_dir_all(dir).map_err(|e| e.to_string())?; + let content = build_toml(config); + std::fs::write(&path, content).map_err(|e| e.to_string())?; + println!("Config written to {}", path.display()); + Ok(()) +} + +fn build_toml(config: &Config) -> String { + let mut out = String::new(); + match config.provider { + Provider::Imap => { + // provider line only needed when non-default, but write it for clarity + out.push_str("provider = \"imap\"\n\n"); + if let Some(imap) = &config.imap { + out.push_str("[imap]\n"); + out.push_str(&format!("host = {:?}\n", imap.host)); + out.push_str(&format!("port = {}\n", imap.port)); + out.push_str(&format!("username = {:?}\n", imap.username)); + out.push_str(&format!("use_tls = {}\n", imap.use_tls)); + out.push('\n'); + } + if let Some(smtp) = &config.smtp { + out.push_str("[smtp]\n"); + out.push_str(&format!("host = {:?}\n", smtp.host)); + out.push_str(&format!("port = {}\n", smtp.port)); + out.push_str(&format!("username = {:?}\n", smtp.username)); + let tls = match &smtp.tls_mode { + TlsMode::None => "none", + TlsMode::Starttls => "starttls", + TlsMode::Smtps => "smtps", + }; + out.push_str(&format!("tls_mode = {:?}\n", tls)); + out.push_str(&format!("from = {:?}\n", smtp.from)); + } + } + Provider::Proton => { + out.push_str("provider = \"proton\"\n\n"); + if let Some(proton) = &config.proton { + out.push_str("[proton]\n"); + out.push_str(&format!("username = {:?}\n", proton.username)); + } + out.push('\n'); + if let Some(bridge) = &config.bridge { + out.push_str("[bridge]\n"); + out.push_str(&format!("imap_port = {}\n", bridge.imap_port)); + out.push_str(&format!("smtp_port = {}\n", bridge.smtp_port)); + } + } + } + out +} + +// ── public API ─────────────────────────────────────────────────────────────── + +/// First-time setup wizard. Runs before raw mode on plain stdout. +pub fn run_first_time_setup() -> Result { + println!("=== tuimail first-time setup ==="); + println!("Passwords will be stored in the OS keychain.\n"); + + let provider_str = loop { + let v = prompt("Provider (imap/proton)", Some("imap")); + match v.to_lowercase().as_str() { + "imap" | "proton" => break v.to_lowercase(), + _ => println!("Please enter 'imap' or 'proton'"), + } + }; + + let config = if provider_str == "imap" { + setup_imap(None) + } else { + setup_proton(None) + }?; + + write_config(&config)?; + Ok(config) +} + +/// Re-run the wizard with existing values pre-filled (--configure). +pub fn run_configure(existing: &Config) -> Result<(), String> { + println!("=== tuimail configure ==="); + println!("Press Enter to keep the current value. Passwords: Enter to keep stored credential.\n"); + + let config = match existing.provider { + Provider::Imap => setup_imap(Some(existing)), + Provider::Proton => setup_proton(Some(existing)), + }?; + + write_config(&config)?; + Ok(()) +} + +// ── provider-specific flows ─────────────────────────────────────────────────── + +fn setup_imap(existing: Option<&Config>) -> Result { + let ex_imap = existing.and_then(|c| c.imap.as_ref()); + let ex_smtp = existing.and_then(|c| c.smtp.as_ref()); + + println!("--- IMAP settings ---"); + let imap_host = prompt("IMAP host", ex_imap.map(|i| i.host.as_str())); + let imap_port: u16 = prompt("IMAP port", ex_imap.map(|_| "").or(Some("993"))) + .parse() + .unwrap_or(993); + 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() { + 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)?; + } + + println!("\n--- SMTP settings ---"); + let smtp_host = prompt("SMTP host", ex_smtp.map(|s| s.host.as_str())); + let smtp_port: u16 = prompt("SMTP port", ex_smtp.map(|_| "").or(Some("465"))) + .parse() + .unwrap_or(465); + let smtp_user = prompt("SMTP username (email)", ex_smtp.map(|s| s.username.as_str())); + let smtp_tls = prompt_tls_mode(ex_smtp.map(|s| &s.tls_mode)); + let smtp_from = prompt("From address (e.g. Name )", ex_smtp.map(|s| s.from.as_str())); + + 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 { + credentials::set(credentials::SMTP_PASSWORD, pw)?; + } + + Ok(Config { + provider: Provider::Imap, + imap: Some(ImapConfig { + host: imap_host, + port: imap_port, + username: imap_user, + password: None, // stored in keychain + use_tls: imap_tls, + }), + smtp: Some(SmtpConfig { + host: smtp_host, + port: smtp_port, + username: smtp_user, + password: None, // stored in keychain + tls_mode: smtp_tls, + from: smtp_from, + }), + proton: None, + bridge: None, + }) +} + +fn setup_proton(existing: Option<&Config>) -> Result { + let ex_proton = existing.and_then(|c| c.proton.as_ref()); + let ex_bridge = existing.and_then(|c| c.bridge.as_ref()); + + 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() { + 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)?; + } + + let two_pw = prompt_bool( + "Use two-password mode?", + 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() { + 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)?; + } + } else { + credentials::delete(credentials::PROTON_MAILBOX_PASSWORD); + } + + let imap_port: u16 = prompt( + "Bridge IMAP port", + ex_bridge.map(|_| "").or(Some("1143")), + ) + .parse() + .unwrap_or(1143); + let smtp_port: u16 = prompt( + "Bridge SMTP port", + ex_bridge.map(|_| "").or(Some("1025")), + ) + .parse() + .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)?; + println!("Generated bridge local password (stored in keychain)."); + } + + Ok(Config { + provider: Provider::Proton, + imap: None, + smtp: None, + proton: Some(ProtonConfig { + username, + password: None, + mailbox_password: None, + user_key_passphrase: ex_proton.and_then(|p| p.user_key_passphrase.clone()), + }), + bridge: Some(BridgeConfig { + imap_port, + smtp_port, + local_password: None, + }), + }) +} diff --git a/src/smtp.rs b/src/smtp.rs index 949ab68..8dfd428 100644 --- a/src/smtp.rs +++ b/src/smtp.rs @@ -25,7 +25,7 @@ pub(crate) fn send_email( .body(body.to_string()) .map_err(|e| e.to_string())?; - let creds = Credentials::new(cfg.username.clone(), cfg.password.clone()); + let creds = Credentials::new(cfg.username.clone(), cfg.password.clone().unwrap_or_default()); let transport = match cfg.tls_mode { TlsMode::Starttls => {