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_<KEY> 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 <noreply@anthropic.com>
This commit is contained in:
Shautvast 2026-02-25 10:50:23 +01:00
parent a8ea1fb5bb
commit facb44d561
13 changed files with 1219 additions and 65 deletions

673
Cargo.lock generated
View file

@ -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",
]

View file

@ -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"] }
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"] }

View file

@ -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 <you@example.com>"
```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 <addr>` |
> Password is stored in the OS keychain. Use `--configure` to set or update it.

View file

@ -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 <you@example.com>"
@ -27,10 +32,7 @@ from = "Your Name <you@example.com>"
#
# [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

View file

@ -21,4 +21,13 @@ env_logger = "0.11"
aes = "0.8"
cfb-mode = "0.8"
sha1 = "0.10"
tracing = "0.1.44"
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"] }

View file

@ -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<Self> {
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();
}
}
}

View file

@ -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<String>,
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<String>,
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<String>,
pub mailbox_password: Option<String>,
pub user_key_passphrase: Option<String>,
}
@ -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<String>,
}
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<Self, Box<dyn std::error::Error>> {
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(),
},
})
}

View file

@ -47,13 +47,15 @@ pub(crate) fn connect(config: &Config) -> Result<ImapSession, String> {
.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))
}

39
src/credentials.rs Normal file
View file

@ -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<String, String> {
// 1. OS keychain
let keychain_err = match keyring::Entry::new(SERVICE, key) {
Ok(entry) => match entry.get_password() {
Ok(val) => return Ok(val),
Err(e) => format!("{e}"),
},
Err(e) => format!("entry creation failed: {e}"),
};
// 2. env var: TUIMAIL_<KEY_UPPERCASE>
let env_key = format!("TUIMAIL_{}", key.to_uppercase());
std::env::var(&env_key).map_err(|_| {
format!(
"Credential '{key}' not found (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();
}
}

View file

@ -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;

View file

@ -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);
});

314
src/setup.rs Normal file
View file

@ -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<String> {
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<Config, String> {
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<Config, String> {
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 <addr@example.com>)", 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<Config, String> {
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,
}),
})
}

View file

@ -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 => {