secured bridge communication

This commit is contained in:
Shautvast 2026-03-02 16:27:46 +01:00
parent 9375b5b96f
commit 5a3ac2eed9
11 changed files with 274 additions and 49 deletions

151
Cargo.lock generated
View file

@ -336,6 +336,28 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "aws-lc-rs"
version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9a7b350e3bb1767102698302bc37256cbd48422809984b98d292c40e2579aa9"
dependencies = [
"aws-lc-sys",
"zeroize",
]
[[package]]
name = "aws-lc-sys"
version = "0.37.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b092fe214090261288111db7a2b2c2118e5a7f30dc2569f1732c4069a6840549"
dependencies = [
"cc",
"cmake",
"dunce",
"fs_extra",
]
[[package]] [[package]]
name = "base16ct" name = "base16ct"
version = "0.2.0" version = "0.2.0"
@ -587,6 +609,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2"
dependencies = [ dependencies = [
"find-msvc-tools", "find-msvc-tools",
"jobserver",
"libc",
"shlex", "shlex",
] ]
@ -676,6 +700,15 @@ dependencies = [
"digest 0.10.7", "digest 0.10.7",
] ]
[[package]]
name = "cmake"
version = "0.1.57"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d"
dependencies = [
"cc",
]
[[package]] [[package]]
name = "colorchoice" name = "colorchoice"
version = "1.0.4" version = "1.0.4"
@ -1226,6 +1259,12 @@ dependencies = [
"dtoa", "dtoa",
] ]
[[package]]
name = "dunce"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]] [[package]]
name = "eax" name = "eax"
version = "0.5.0" version = "0.5.0"
@ -1553,6 +1592,12 @@ dependencies = [
"percent-encoding", "percent-encoding",
] ]
[[package]]
name = "fs_extra"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]] [[package]]
name = "futures-channel" name = "futures-channel"
version = "0.3.32" version = "0.3.32"
@ -2188,6 +2233,16 @@ dependencies = [
"syn 2.0.116", "syn 2.0.116",
] ]
[[package]]
name = "jobserver"
version = "0.1.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
dependencies = [
"getrandom 0.3.4",
"libc",
]
[[package]] [[package]]
name = "js-sys" name = "js-sys"
version = "0.3.85" version = "0.3.85"
@ -2939,6 +2994,16 @@ dependencies = [
"subtle", "subtle",
] ]
[[package]]
name = "pem"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
dependencies = [
"base64 0.22.1",
"serde_core",
]
[[package]] [[package]]
name = "pem-rfc7468" name = "pem-rfc7468"
version = "0.7.0" version = "0.7.0"
@ -3362,11 +3427,14 @@ dependencies = [
"pgp", "pgp",
"pwhash", "pwhash",
"rand 0.8.5", "rand 0.8.5",
"rcgen",
"reqwest", "reqwest",
"rustls",
"serde", "serde",
"serde_json", "serde_json",
"sha2 0.10.9", "sha2 0.10.9",
"tokio", "tokio",
"tokio-rustls",
"toml", "toml",
"tracing", "tracing",
] ]
@ -3601,6 +3669,19 @@ dependencies = [
"unicode-width", "unicode-width",
] ]
[[package]]
name = "rcgen"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2"
dependencies = [
"pem",
"ring",
"rustls-pki-types",
"time",
"yasna",
]
[[package]] [[package]]
name = "redox_syscall" name = "redox_syscall"
version = "0.5.18" version = "0.5.18"
@ -3702,6 +3783,20 @@ dependencies = [
"subtle", "subtle",
] ]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]] [[package]]
name = "ripemd" name = "ripemd"
version = "0.1.3" version = "0.1.3"
@ -3788,6 +3883,21 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "rustls"
version = "0.23.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
dependencies = [
"aws-lc-rs",
"log",
"once_cell",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]] [[package]]
name = "rustls-pki-types" name = "rustls-pki-types"
version = "1.14.0" version = "1.14.0"
@ -3797,6 +3907,18 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "rustls-webpki"
version = "0.103.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
dependencies = [
"aws-lc-rs",
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]] [[package]]
name = "rustversion" name = "rustversion"
version = "1.0.22" version = "1.0.22"
@ -4237,9 +4359,9 @@ dependencies = [
[[package]] [[package]]
name = "subtle" name = "subtle"
version = "2.4.1" version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]] [[package]]
name = "syn" name = "syn"
@ -4500,6 +4622,16 @@ dependencies = [
"tokio", "tokio",
] ]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
"rustls",
"tokio",
]
[[package]] [[package]]
name = "toml" name = "toml"
version = "1.0.2+spec-1.1.0" version = "1.0.2+spec-1.1.0"
@ -4765,6 +4897,12 @@ dependencies = [
"subtle", "subtle",
] ]
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]] [[package]]
name = "url" name = "url"
version = "2.5.8" version = "2.5.8"
@ -5441,6 +5579,15 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
[[package]]
name = "yasna"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd"
dependencies = [
"time",
]
[[package]] [[package]]
name = "yoke" name = "yoke"
version = "0.8.1" version = "0.8.1"

View file

@ -1,9 +1,9 @@
# tuimail — User Guide # Tuimail
Tuimail is a terminal email client. It shows your inbox in a split-pane view: Tuimail is a terminal email client. It shows your inbox in a split-pane view:
the email list on top, the message preview on the bottom. the email list on top, the message preview on the bottom.
#### Safety features ### Safety features
- no html rendering (conversion to plain text) - no html rendering (conversion to plain text)
- no tracking pixels - no tracking pixels
- no javascript - no javascript
@ -12,7 +12,7 @@ the email list on top, the message preview on the bottom.
- safe communication to remote hosts (standard TLS, PGP for Proton) - safe communication to remote hosts (standard TLS, PGP for Proton)
--- ---
### Configuring # Configuring
Sorry, there is no binary release for now. This setup requires that you have rust cargo installed. Sorry, there is no binary release for now. This setup requires that you have rust cargo installed.
Run the setup wizard: Run the setup wizard:

View file

@ -19,6 +19,9 @@ pgp = { version = "0.15", default-features = false } # rpgp — OpenPGP decrypt
chrono = "0.4" chrono = "0.4"
env_logger = "0.11" env_logger = "0.11"
tracing = "0.1.44" tracing = "0.1.44"
rcgen = "0.13"
tokio-rustls = "0.26"
rustls = "0.23"
[target.'cfg(target_os = "macos")'.dependencies] [target.'cfg(target_os = "macos")'.dependencies]
keyring = { version = "3", features = ["apple-native"] } keyring = { version = "3", features = ["apple-native"] }

View file

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

View file

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

View file

@ -9,8 +9,9 @@ use crate::api::ApiClient;
use crate::{SharedState, crypto}; use crate::{SharedState, crypto};
use base64::Engine; use base64::Engine;
use base64::engine::general_purpose::STANDARD as B64; use base64::engine::general_purpose::STANDARD as B64;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
use tokio::net::{TcpListener, TcpStream}; use tokio::net::TcpListener;
use tokio_rustls::TlsAcceptor;
use tracing::error; use tracing::error;
// ── Public entry point ──────────────────────────────────────────────────────── // ── Public entry point ────────────────────────────────────────────────────────
@ -20,12 +21,20 @@ use tracing::error;
pub async fn run_with_listener( pub async fn run_with_listener(
state: SharedState, state: SharedState,
listener: TcpListener, listener: TcpListener,
acceptor: TlsAcceptor,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
loop { loop {
let (socket, _addr) = listener.accept().await?; let (socket, _addr) = listener.accept().await?;
let tls_stream = match acceptor.accept(socket).await {
Ok(s) => s,
Err(e) => {
error!("SMTP TLS handshake failed: {e}");
continue;
}
};
let state = Arc::clone(&state); let state = Arc::clone(&state);
tokio::spawn(async move { tokio::spawn(async move {
if let Err(e) = handle_connection(socket, state).await { if let Err(e) = handle_connection(tls_stream, state).await {
error!("SMTP connection error: {e}"); error!("SMTP connection error: {e}");
} }
}); });
@ -34,12 +43,15 @@ pub async fn run_with_listener(
// ── Per-connection handler ──────────────────────────────────────────────────── // ── Per-connection handler ────────────────────────────────────────────────────
async fn handle_connection( async fn handle_connection<S>(
socket: TcpStream, socket: S,
state: SharedState, state: SharedState,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>>
let (reader, mut writer) = socket.into_split(); where
let mut lines = BufReader::new(reader).lines(); S: AsyncRead + AsyncWrite + Unpin + Send,
{
let (rd, mut writer) = tokio::io::split(socket);
let mut lines = BufReader::new(rd).lines();
let mut authenticated = false; let mut authenticated = false;
let mut mail_from = String::new(); let mut mail_from = String::new();

View file

@ -46,6 +46,8 @@ pub struct SmtpConfig {
pub password: Option<String>, pub password: Option<String>,
pub tls_mode: TlsMode, pub tls_mode: TlsMode,
pub from: String, pub from: String,
#[serde(skip)]
pub tls_cert_der: Option<Vec<u8>>,
} }
#[derive(Debug, Deserialize, Clone)] #[derive(Debug, Deserialize, Clone)]
@ -56,6 +58,8 @@ pub struct ImapConfig {
#[serde(default)] #[serde(default)]
pub password: Option<String>, pub password: Option<String>,
pub use_tls: bool, pub use_tls: bool,
#[serde(skip)]
pub tls_cert_der: Option<Vec<u8>>,
} }
/// Mirror of `proton_bridge::config::ProtonConfig` — used for config parsing /// Mirror of `proton_bridge::config::ProtonConfig` — used for config parsing
@ -150,7 +154,8 @@ impl Config {
port: b.imap_port, port: b.imap_port,
username: "bridge".into(), username: "bridge".into(),
password: b.local_password.clone(), password: b.local_password.clone(),
use_tls: false, use_tls: true,
tls_cert_der: None, // injected by main.rs after start()
}) })
} }
} }
@ -178,8 +183,9 @@ impl Config {
port: b.smtp_port, port: b.smtp_port,
username: "bridge".into(), username: "bridge".into(),
password: b.local_password.clone(), password: b.local_password.clone(),
tls_mode: TlsMode::None, tls_mode: TlsMode::Smtps,
from, from,
tls_cert_der: None, // injected by main.rs after start()
}) })
} }
} }

View file

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

View file

@ -46,6 +46,16 @@ fn main() -> io::Result<()> {
exit(1); exit(1);
}); });
// ── Derive effective IMAP/SMTP configs for the chosen provider ──
let mut imap_cfg = config.effective_imap().unwrap_or_else(|e| {
eprintln!("{e}");
exit(1);
});
let mut smtp_cfg = config.effective_smtp().unwrap_or_else(|e| {
eprintln!("{e}");
exit(1);
});
// ── Start bridge if needed (before entering raw mode so output is clean) ── // ── Start bridge if needed (before entering raw mode so output is clean) ──
if config.provider == Provider::Proton { if config.provider == Provider::Proton {
let bridge_cfg = config.as_bridge_config().unwrap_or_else(|e| { let bridge_cfg = config.as_bridge_config().unwrap_or_else(|e| {
@ -53,22 +63,15 @@ fn main() -> io::Result<()> {
exit(1); exit(1);
}); });
eprint!("Starting ProtonMail bridge..."); eprint!("Starting ProtonMail bridge...");
proton_bridge::start(bridge_cfg).unwrap_or_else(|e| { let cert_der = proton_bridge::start(bridge_cfg).unwrap_or_else(|e| {
eprintln!("\nBridge failed to start: {e}"); eprintln!("\nBridge failed to start: {e}");
exit(1); exit(1);
}); });
eprintln!(" ready."); eprintln!(" ready.");
imap_cfg.tls_cert_der = Some(cert_der.clone());
smtp_cfg.tls_cert_der = Some(cert_der);
} }
// ── Derive effective IMAP/SMTP configs for the chosen provider ──
let imap_cfg = config.effective_imap().unwrap_or_else(|e| {
eprintln!("{e}");
exit(1);
});
let smtp_cfg = config.effective_smtp().unwrap_or_else(|e| {
eprintln!("{e}");
exit(1);
});
let runtime_config = Config { let runtime_config = Config {
imap: Some(imap_cfg), imap: Some(imap_cfg),
smtp: Some(smtp_cfg), smtp: Some(smtp_cfg),

View file

@ -229,6 +229,7 @@ fn setup_imap(existing: Option<&Config>) -> Result<Config, String> {
username: imap_user, username: imap_user,
password: None, // stored in keychain password: None, // stored in keychain
use_tls: imap_tls, use_tls: imap_tls,
tls_cert_der: None,
}), }),
smtp: Some(SmtpConfig { smtp: Some(SmtpConfig {
host: smtp_host, host: smtp_host,
@ -237,6 +238,7 @@ fn setup_imap(existing: Option<&Config>) -> Result<Config, String> {
password: None, // stored in keychain password: None, // stored in keychain
tls_mode: smtp_tls, tls_mode: smtp_tls,
from: smtp_from, from: smtp_from,
tls_cert_der: None,
}), }),
proton: None, proton: None,
bridge: None, bridge: None,

View file

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