Integrate proton-bridge in-process and silence verbose logging
- Add proton-bridge as optional dep behind `proton` feature flag - New proton-bridge/src/lib.rs: pub fn start() spins a background Tokio thread, pre-binds ports, and signals readiness via mpsc before returning - src/main.rs: conditionally starts bridge before TUI enters raw mode; derives effective IMAP/SMTP config via Provider enum - src/config.rs: add Provider enum, optional imap/smtp, ProtonConfig/ BridgeConfig mirrors, effective_imap/smtp() helpers - Remove all per-operation eprintln!/println! from imap_server, smtp_server, and api.rs that fired during TUI operation and corrupted the display - config.toml.example: unified format covering both imap and proton providers Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
8826f3bc95
commit
2287b08cb5
15 changed files with 548 additions and 362 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -1,6 +1,6 @@
|
||||||
/target
|
/target
|
||||||
docker-data/
|
docker-data/
|
||||||
config.toml
|
config.toml.old
|
||||||
.idea/
|
.idea/
|
||||||
bridge.toml
|
bridge.toml
|
||||||
session.json
|
session.json
|
||||||
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -4212,6 +4212,7 @@ dependencies = [
|
||||||
"lettre",
|
"lettre",
|
||||||
"mailparse",
|
"mailparse",
|
||||||
"native-tls",
|
"native-tls",
|
||||||
|
"proton-bridge",
|
||||||
"quoted_printable",
|
"quoted_printable",
|
||||||
"ratatui",
|
"ratatui",
|
||||||
"regex",
|
"regex",
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,11 @@ name = "tuimail"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
proton = ["dep:proton-bridge"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
proton-bridge = { path = "proton-bridge", optional = true }
|
||||||
ratatui = "0.30"
|
ratatui = "0.30"
|
||||||
crossterm = "0.29"
|
crossterm = "0.29"
|
||||||
imap = "2.4"
|
imap = "2.4"
|
||||||
|
|
|
||||||
40
USAGE.md
40
USAGE.md
|
|
@ -38,14 +38,50 @@ from = "Your Name <you@example.com>"
|
||||||
|----------|-----------|-----------|---------|-----------|-----------|----------|
|
|----------|-----------|-----------|---------|-----------|-----------|----------|
|
||||||
| Gmail | imap.gmail.com | 993 | true | smtp.gmail.com | 465 | smtps |
|
| Gmail | imap.gmail.com | 993 | true | smtp.gmail.com | 465 | smtps |
|
||||||
| Outlook/Hotmail | outlook.office365.com | 993 | true | smtp.office365.com | 587 | starttls |
|
| Outlook/Hotmail | outlook.office365.com | 993 | true | smtp.office365.com | 587 | starttls |
|
||||||
| ProtonMail (Bridge) | 127.0.0.1 | 1143 | false | 127.0.0.1 | 1025 | none |
|
| ProtonMail | see ProtonMail section below ||||| |
|
||||||
| Local test server | localhost | 143 | false | localhost | 25 | none |
|
| Local test server | localhost | 143 | false | localhost | 25 | none |
|
||||||
|
|
||||||
> **Gmail note:** You must use an [App Password](https://myaccount.google.com/apppasswords),
|
> **Gmail note:** You must use an [App Password](https://myaccount.google.com/apppasswords),
|
||||||
> not your regular password. Enable 2-Step Verification first, then generate an
|
> not your regular password. Enable 2-Step Verification first, then generate an
|
||||||
> App Password for "Mail".
|
> App Password for "Mail".
|
||||||
|
|
||||||
Then run:
|
### ProtonMail
|
||||||
|
|
||||||
|
tuimail can talk to ProtonMail directly — no separate bridge process needed.
|
||||||
|
The bridge starts automatically in-process when `provider = "proton"` is set.
|
||||||
|
|
||||||
|
**1. Build with ProtonMail support:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build --features proton
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Configure `config.toml`** (remove or comment out `[imap]` / `[smtp]`):
|
||||||
|
|
||||||
|
```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
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Run:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo run --features proton
|
||||||
|
```
|
||||||
|
|
||||||
|
The bridge authenticates with ProtonMail before the TUI opens. Messages are
|
||||||
|
decrypted on the fly; sent mail is encrypted end-to-end automatically.
|
||||||
|
|
||||||
|
Then run (standard providers):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo run --release
|
cargo run --release
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,36 @@
|
||||||
|
# ── Standard IMAP/SMTP provider (default) ─────────────────────────────────────
|
||||||
|
# provider = "imap" # optional — "imap" is the default
|
||||||
|
|
||||||
[imap]
|
[imap]
|
||||||
host = "localhost"
|
host = "imap.gmail.com" # your provider's IMAP server
|
||||||
port = 143
|
port = 993
|
||||||
username = "test@example.com"
|
username = "you@example.com"
|
||||||
password = "password123"
|
password = "your-app-password"
|
||||||
use_tls = false
|
use_tls = true
|
||||||
|
|
||||||
[smtp]
|
[smtp]
|
||||||
host = "localhost"
|
host = "smtp.gmail.com" # your provider's SMTP server
|
||||||
port = 25
|
port = 465
|
||||||
username = "test@example.com"
|
username = "you@example.com"
|
||||||
password = "password123"
|
password = "your-app-password"
|
||||||
# tls_mode options:
|
# tls_mode options: none | starttls | smtps
|
||||||
# none — plain text (port 25 or unencrypted 587)
|
tls_mode = "smtps"
|
||||||
# starttls — upgrades mid-session (port 587, most providers)
|
from = "Your Name <you@example.com>"
|
||||||
# smtps — TLS from first byte (port 465, Gmail "SSL")
|
|
||||||
tls_mode = "none"
|
|
||||||
from = "Test User <test@example.com>"
|
# ── ProtonMail provider ────────────────────────────────────────────────────────
|
||||||
|
# Uncomment the lines below (and remove or comment out [imap]/[smtp] above).
|
||||||
|
# Build with: cargo build --features proton
|
||||||
|
# Run with: cargo run --features proton
|
||||||
|
#
|
||||||
|
# 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 between tuimail and the bridge
|
||||||
|
|
|
||||||
|
|
@ -495,7 +495,6 @@ impl<'a> ApiClient<'a> {
|
||||||
}
|
}
|
||||||
|
|
||||||
let body = resp.text().await.map_err(|e| e.to_string())?;
|
let body = resp.text().await.map_err(|e| e.to_string())?;
|
||||||
println!("key_salts raw: {body}");
|
|
||||||
let env: Envelope<KeySaltsPayload> =
|
let env: Envelope<KeySaltsPayload> =
|
||||||
serde_json::from_str(&body).map_err(|e| format!("key_salts parse: {e}"))?;
|
serde_json::from_str(&body).map_err(|e| format!("key_salts parse: {e}"))?;
|
||||||
if env.code != 1000 {
|
if env.code != 1000 {
|
||||||
|
|
@ -537,13 +536,6 @@ impl<'a> ApiClient<'a> {
|
||||||
attachment_key_packets: vec![],
|
attachment_key_packets: vec![],
|
||||||
action: 0,
|
action: 0,
|
||||||
};
|
};
|
||||||
// Log the full request JSON (body is long, show first 300 chars for structure check).
|
|
||||||
let req_json = serde_json::to_string(&req).unwrap_or_else(|e| e.to_string());
|
|
||||||
eprintln!(
|
|
||||||
"SMTP create_draft POST /mail/v4/messages AddressID={address_id} to={to_addr}\n\
|
|
||||||
[req-json first 300]: {}",
|
|
||||||
&req_json[..req_json.len().min(300)]
|
|
||||||
);
|
|
||||||
let resp = self
|
let resp = self
|
||||||
.client
|
.client
|
||||||
.post(format!("{}/mail/v4/messages", API_BASE))
|
.post(format!("{}/mail/v4/messages", API_BASE))
|
||||||
|
|
@ -628,10 +620,6 @@ impl<'a> ApiClient<'a> {
|
||||||
};
|
};
|
||||||
// Note: send uses POST to the same path as the draft — no "/send" suffix.
|
// Note: send uses POST to the same path as the draft — no "/send" suffix.
|
||||||
// (go-proton-api: SendDraft does POST /{id}, UpdateDraft does PUT /{id})
|
// (go-proton-api: SendDraft does POST /{id}, UpdateDraft does PUT /{id})
|
||||||
eprintln!(
|
|
||||||
"SMTP send_draft POST /mail/v4/messages/{draft_id}\n body: {}",
|
|
||||||
serde_json::to_string(&req).unwrap_or_else(|e| e.to_string())
|
|
||||||
);
|
|
||||||
let resp = self
|
let resp = self
|
||||||
.client
|
.client
|
||||||
.post(format!("{}/mail/v4/messages/{}", API_BASE, draft_id))
|
.post(format!("{}/mail/v4/messages/{}", API_BASE, draft_id))
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::fs;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Clone)]
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
|
|
@ -32,9 +31,3 @@ pub struct BridgeConfig {
|
||||||
pub local_password: String,
|
pub local_password: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Config {
|
|
||||||
pub fn load() -> Result<Self, Box<dyn std::error::Error>> {
|
|
||||||
let content = fs::read_to_string("bridge.toml")?;
|
|
||||||
Ok(toml::from_str(&content)?)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -16,9 +16,17 @@ use crate::{crypto, SharedState};
|
||||||
pub async fn run(state: SharedState, port: u16) -> Result<(), Box<dyn std::error::Error>> {
|
pub async fn run(state: SharedState, port: u16) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let listener = TcpListener::bind(format!("0.0.0.0:{port}")).await?;
|
let listener = TcpListener::bind(format!("0.0.0.0:{port}")).await?;
|
||||||
println!("IMAP listening on port {port}");
|
println!("IMAP listening on port {port}");
|
||||||
|
run_with_listener(state, listener).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accept connections on an already-bound listener. Used by the embedded bridge
|
||||||
|
/// so ports are guaranteed to be occupied before `start()` returns.
|
||||||
|
pub async fn run_with_listener(
|
||||||
|
state: SharedState,
|
||||||
|
listener: TcpListener,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
loop {
|
loop {
|
||||||
let (socket, addr) = listener.accept().await?;
|
let (socket, _addr) = listener.accept().await?;
|
||||||
eprintln!("IMAP: connection from {addr}");
|
|
||||||
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(socket, state).await {
|
||||||
|
|
@ -47,7 +55,6 @@ async fn handle_connection(
|
||||||
if line.is_empty() {
|
if line.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
eprintln!("IMAP < {line}");
|
|
||||||
|
|
||||||
// Every IMAP command is "TAG COMMAND [rest…]"
|
// Every IMAP command is "TAG COMMAND [rest…]"
|
||||||
let mut parts = line.splitn(3, ' ');
|
let mut parts = line.splitn(3, ' ');
|
||||||
|
|
@ -59,9 +66,9 @@ async fn handle_connection(
|
||||||
let response =
|
let response =
|
||||||
dispatch(&tag, &cmd, &rest, &state, &mut authenticated).await;
|
dispatch(&tag, &cmd, &rest, &state, &mut authenticated).await;
|
||||||
|
|
||||||
for log_line in response.lines() {
|
// for log_line in response.lines() {
|
||||||
eprintln!("IMAP > {log_line}");
|
// eprintln!("IMAP > {log_line}");
|
||||||
}
|
// }
|
||||||
writer.write_all(response.as_bytes()).await?;
|
writer.write_all(response.as_bytes()).await?;
|
||||||
if logout {
|
if logout {
|
||||||
break;
|
break;
|
||||||
|
|
|
||||||
262
proton-bridge/src/lib.rs
Normal file
262
proton-bridge/src/lib.rs
Normal file
|
|
@ -0,0 +1,262 @@
|
||||||
|
pub mod config;
|
||||||
|
mod api;
|
||||||
|
mod auth;
|
||||||
|
mod crypto;
|
||||||
|
mod imap_server;
|
||||||
|
mod smtp_server;
|
||||||
|
mod srp;
|
||||||
|
mod store;
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use api::{ApiClient, LABEL_INBOX};
|
||||||
|
use store::MessageStore;
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
// ── Shared bridge state ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// State shared between the IMAP and SMTP server tasks.
|
||||||
|
pub struct BridgeState {
|
||||||
|
pub store: MessageStore,
|
||||||
|
pub key_pool: Vec<crypto::PrivateKey>,
|
||||||
|
pub http_client: reqwest::Client,
|
||||||
|
pub session: auth::Session,
|
||||||
|
pub config: config::Config,
|
||||||
|
pub sender_addresses: Vec<(String, String)>,
|
||||||
|
pub own_public_keys: HashMap<String, String>,
|
||||||
|
pub sender_key_indices: HashMap<String, usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type SharedState = Arc<Mutex<BridgeState>>;
|
||||||
|
|
||||||
|
// ── Public entry point ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Authenticate with ProtonMail, bind IMAP and SMTP ports, then return `Ok(())`.
|
||||||
|
/// The servers keep running in background Tokio tasks on a dedicated OS thread.
|
||||||
|
/// Returns `Err` if authentication or port binding fails.
|
||||||
|
///
|
||||||
|
/// This is the integration entry point for tuimail. It is synchronous so the
|
||||||
|
/// caller (tuimail's main thread) needs no Tokio runtime of its own.
|
||||||
|
pub fn start(config: config::Config) -> Result<(), String> {
|
||||||
|
let (ready_tx, ready_rx) = std::sync::mpsc::channel::<Result<(), String>>();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let rt = tokio::runtime::Builder::new_multi_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.expect("tokio runtime");
|
||||||
|
rt.block_on(async move {
|
||||||
|
match run(config).await {
|
||||||
|
Ok(()) => {
|
||||||
|
let _ = ready_tx.send(Ok(()));
|
||||||
|
// Keep the runtime alive so spawned server tasks keep running.
|
||||||
|
std::future::pending::<()>().await;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let _ = ready_tx.send(Err(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
ready_rx
|
||||||
|
.recv()
|
||||||
|
.map_err(|_| "bridge thread exited unexpectedly".to_string())?
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Async startup ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async fn run(config: config::Config) -> Result<(), String> {
|
||||||
|
let client = auth::build_client().map_err(|e| format!("HTTP client: {e}"))?;
|
||||||
|
let session = auth::authenticate(&client, &config.proton)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Authentication failed: {e}"))?;
|
||||||
|
eprintln!("Authenticated as {}", config.proton.username);
|
||||||
|
|
||||||
|
let api = ApiClient::new(&client, &session);
|
||||||
|
|
||||||
|
let (key_pool, sender_addresses, own_public_keys, sender_key_indices) =
|
||||||
|
unlock_key_pool(&api, &config).await?;
|
||||||
|
eprintln!("{} key(s) in pool", key_pool.len());
|
||||||
|
crypto::dump_key_pool(&key_pool);
|
||||||
|
|
||||||
|
let (messages, total) = api
|
||||||
|
.list_messages(LABEL_INBOX, 0, 50)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("list_messages: {e}"))?;
|
||||||
|
let mut store = MessageStore::new();
|
||||||
|
store.load_all(messages);
|
||||||
|
eprintln!("Inbox: {} messages ({total} total)", store.count());
|
||||||
|
|
||||||
|
// Bind ports before spawning tasks — ports are occupied when start() returns.
|
||||||
|
let imap_listener = TcpListener::bind(("127.0.0.1", config.bridge.imap_port))
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("bind IMAP :{}: {e}", config.bridge.imap_port))?;
|
||||||
|
let smtp_listener = TcpListener::bind(("127.0.0.1", config.bridge.smtp_port))
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("bind SMTP :{}: {e}", config.bridge.smtp_port))?;
|
||||||
|
|
||||||
|
let state: SharedState = Arc::new(Mutex::new(BridgeState {
|
||||||
|
store,
|
||||||
|
key_pool,
|
||||||
|
http_client: client,
|
||||||
|
session,
|
||||||
|
config,
|
||||||
|
sender_addresses,
|
||||||
|
own_public_keys,
|
||||||
|
sender_key_indices,
|
||||||
|
}));
|
||||||
|
|
||||||
|
let imap_state = Arc::clone(&state);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = imap_server::run_with_listener(imap_state, imap_listener).await {
|
||||||
|
eprintln!("IMAP server error: {e}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = smtp_server::run_with_listener(state, smtp_listener).await {
|
||||||
|
eprintln!("SMTP server error: {e}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(()) // ← ports are bound; start() unblocks here
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Key pool setup ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async fn unlock_key_pool(
|
||||||
|
api: &ApiClient<'_>,
|
||||||
|
config: &config::Config,
|
||||||
|
) -> Result<
|
||||||
|
(
|
||||||
|
Vec<crypto::PrivateKey>,
|
||||||
|
Vec<(String, String)>,
|
||||||
|
HashMap<String, String>,
|
||||||
|
HashMap<String, usize>,
|
||||||
|
),
|
||||||
|
String,
|
||||||
|
> {
|
||||||
|
let (user_keys_res, addresses_res, key_salts_res) = tokio::join!(
|
||||||
|
api.get_user_keys(),
|
||||||
|
api.get_addresses(),
|
||||||
|
api.get_key_salts(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let user_keys = user_keys_res.map_err(|e| format!("get_user_keys: {e}"))?;
|
||||||
|
let addresses = addresses_res.map_err(|e| format!("get_addresses: {e}"))?;
|
||||||
|
|
||||||
|
let key_salts = match key_salts_res {
|
||||||
|
Ok(salts) => salts,
|
||||||
|
Err(e) => {
|
||||||
|
if e.contains("403") {
|
||||||
|
auth::Session::delete();
|
||||||
|
return Err(format!(
|
||||||
|
"Session 'locked' scope expired — session.json deleted. \
|
||||||
|
Re-run to enter TOTP and regain 'locked' scope. ({e})"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
eprintln!("get_key_salts: {e} — continuing without salt");
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let password = config
|
||||||
|
.proton
|
||||||
|
.mailbox_password
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or(&config.proton.password);
|
||||||
|
|
||||||
|
let passphrase_for = |key_id: &str| -> String {
|
||||||
|
let salt = key_salts
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.id == key_id)
|
||||||
|
.and_then(|s| s.key_salt.as_deref())
|
||||||
|
.unwrap_or("");
|
||||||
|
crypto::derive_key_passphrase(password, salt).unwrap_or_else(|_| password.to_string())
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut user_private_keys: Vec<crypto::PrivateKey> = Vec::new();
|
||||||
|
for uk in &user_keys {
|
||||||
|
if uk.active != 1 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let salt = key_salts
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.id == uk.id)
|
||||||
|
.and_then(|s| s.key_salt.as_deref())
|
||||||
|
.unwrap_or("");
|
||||||
|
let pp = crypto::derive_key_passphrase(password, salt)
|
||||||
|
.unwrap_or_else(|_| password.to_string());
|
||||||
|
match crypto::PrivateKey::unlock(&uk.private_key, &pp) {
|
||||||
|
Ok(k) => {
|
||||||
|
println!("user key {} unlocked", &uk.id[..8.min(uk.id.len())]);
|
||||||
|
user_private_keys.push(k);
|
||||||
|
}
|
||||||
|
Err(e) => eprintln!("user key {} FAILED: {e}", &uk.id[..8.min(uk.id.len())]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut key_pool: Vec<crypto::PrivateKey> = Vec::new();
|
||||||
|
let mut own_public_keys: HashMap<String, String> = HashMap::new();
|
||||||
|
let mut sender_key_indices: HashMap<String, usize> = HashMap::new();
|
||||||
|
for addr in &addresses {
|
||||||
|
let mut first_active_key_done = false;
|
||||||
|
for ak in &addr.keys {
|
||||||
|
if ak.active != 1 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let pp = if let Some(token) = &ak.token {
|
||||||
|
let mut token_pp = None;
|
||||||
|
for uk in &user_private_keys {
|
||||||
|
if let Ok(p) = crypto::decrypt_token(token, uk) {
|
||||||
|
token_pp = Some(p);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
token_pp.unwrap_or_else(|| passphrase_for(&ak.id))
|
||||||
|
} else {
|
||||||
|
passphrase_for(&ak.id)
|
||||||
|
};
|
||||||
|
match crypto::PrivateKey::unlock(&ak.private_key, &pp) {
|
||||||
|
Ok(k) => {
|
||||||
|
println!(
|
||||||
|
"address key {} ({}) unlocked primary={}",
|
||||||
|
&ak.id[..8.min(ak.id.len())],
|
||||||
|
addr.email,
|
||||||
|
ak.primary
|
||||||
|
);
|
||||||
|
if !first_active_key_done {
|
||||||
|
match k.public_key_armored() {
|
||||||
|
Ok(pk) => {
|
||||||
|
eprintln!(
|
||||||
|
" [own-key] {} → first-active key primary={}",
|
||||||
|
addr.email, ak.primary
|
||||||
|
);
|
||||||
|
own_public_keys.insert(addr.email.clone(), pk);
|
||||||
|
sender_key_indices.insert(addr.email.clone(), key_pool.len());
|
||||||
|
}
|
||||||
|
Err(e) => eprintln!("extract pub key for {}: {e}", addr.email),
|
||||||
|
}
|
||||||
|
first_active_key_done = true;
|
||||||
|
}
|
||||||
|
key_pool.push(k);
|
||||||
|
}
|
||||||
|
Err(e) => eprintln!(
|
||||||
|
"address key {} ({}): {e}",
|
||||||
|
&ak.id[..8.min(ak.id.len())],
|
||||||
|
addr.email
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
key_pool.extend(user_private_keys);
|
||||||
|
|
||||||
|
let sender_addresses: Vec<(String, String)> = addresses
|
||||||
|
.iter()
|
||||||
|
.map(|a| (a.id.clone(), a.email.clone()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
println!("{} own public key(s) from address keys", own_public_keys.len());
|
||||||
|
Ok((key_pool, sender_addresses, own_public_keys, sender_key_indices))
|
||||||
|
}
|
||||||
|
|
@ -1,276 +1,32 @@
|
||||||
mod api;
|
use std::fs;
|
||||||
mod auth;
|
|
||||||
mod config;
|
|
||||||
mod crypto;
|
|
||||||
mod imap_server;
|
|
||||||
mod smtp_server;
|
|
||||||
mod srp;
|
|
||||||
mod store;
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
fn main() {
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use api::{ApiClient, LABEL_INBOX};
|
|
||||||
use store::MessageStore;
|
|
||||||
use tokio::sync::Mutex;
|
|
||||||
|
|
||||||
// ── Shared bridge state ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// State shared between the IMAP and SMTP server tasks.
|
|
||||||
pub struct BridgeState {
|
|
||||||
pub store: MessageStore,
|
|
||||||
pub key_pool: Vec<crypto::PrivateKey>,
|
|
||||||
/// The underlying reqwest client (stored so we can create ApiClient on demand).
|
|
||||||
pub http_client: reqwest::Client,
|
|
||||||
pub session: auth::Session,
|
|
||||||
pub config: config::Config,
|
|
||||||
/// ProtonMail address ID → email pairs (for `AddressID` in draft creation).
|
|
||||||
pub sender_addresses: Vec<(String, String)>,
|
|
||||||
/// email → armored public key for draft self-encryption.
|
|
||||||
/// Uses the first active key per address (matches addrKR.FirstKey() in go-proton-api).
|
|
||||||
pub own_public_keys: HashMap<String, String>,
|
|
||||||
/// email → index in `key_pool` of the signing key for that address.
|
|
||||||
/// The signing key is the same first-active address key used for `own_public_keys`.
|
|
||||||
pub sender_key_indices: HashMap<String, usize>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub type SharedState = Arc<Mutex<BridgeState>>;
|
|
||||||
|
|
||||||
// ── Entry point ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[tokio::main]
|
|
||||||
async fn main() {
|
|
||||||
env_logger::init();
|
env_logger::init();
|
||||||
|
|
||||||
let config = match config::Config::load() {
|
let content = fs::read_to_string("config.toml").unwrap_or_else(|e| {
|
||||||
Ok(c) => c,
|
eprintln!("Failed to read config.toml: {e}");
|
||||||
Err(e) => {
|
std::process::exit(1);
|
||||||
eprintln!("Failed to load bridge.toml: {e}");
|
});
|
||||||
std::process::exit(1);
|
let config: proton_bridge::config::Config = toml::from_str(&content).unwrap_or_else(|e| {
|
||||||
}
|
eprintln!("Failed to parse config.toml: {e}");
|
||||||
};
|
std::process::exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
let client = match auth::build_client() {
|
|
||||||
Ok(c) => c,
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("Failed to build HTTP client: {e}");
|
|
||||||
std::process::exit(1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let session = match auth::authenticate(&client, &config.proton).await {
|
|
||||||
Ok(s) => s,
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("Authentication failed: {e}");
|
|
||||||
std::process::exit(1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
println!("Authenticated as {}", config.proton.username);
|
|
||||||
|
|
||||||
let api = ApiClient::new(&client, &session);
|
|
||||||
|
|
||||||
// Unlock user + address keys, collect address ID/email pairs, and extract
|
|
||||||
// own public keys directly from address private keys for draft self-encryption.
|
|
||||||
let (key_pool, sender_addresses, own_public_keys, sender_key_indices) =
|
|
||||||
match unlock_key_pool(&api, &config).await {
|
|
||||||
Ok(k) => k,
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("{e}");
|
|
||||||
std::process::exit(1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
println!("{} key(s) in pool", key_pool.len());
|
|
||||||
crypto::dump_key_pool(&key_pool);
|
|
||||||
|
|
||||||
// Load the initial inbox into the message store.
|
|
||||||
let (messages, total) = match api.list_messages(LABEL_INBOX, 0, 50).await {
|
|
||||||
Ok(r) => r,
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("list_messages failed: {e}");
|
|
||||||
std::process::exit(1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let mut store = MessageStore::new();
|
|
||||||
store.load_all(messages);
|
|
||||||
println!("Inbox: {} messages ({total} total)", store.count());
|
|
||||||
|
|
||||||
let state: SharedState = Arc::new(Mutex::new(BridgeState {
|
|
||||||
store,
|
|
||||||
key_pool,
|
|
||||||
http_client: client,
|
|
||||||
session,
|
|
||||||
config: config.clone(),
|
|
||||||
sender_addresses,
|
|
||||||
own_public_keys,
|
|
||||||
sender_key_indices,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Spawn IMAP server.
|
|
||||||
let imap_state = Arc::clone(&state);
|
|
||||||
let imap_port = config.bridge.imap_port;
|
let imap_port = config.bridge.imap_port;
|
||||||
tokio::spawn(async move {
|
|
||||||
if let Err(e) = imap_server::run(imap_state, imap_port).await {
|
|
||||||
eprintln!("IMAP server error: {e}");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Spawn SMTP server.
|
|
||||||
let smtp_state = Arc::clone(&state);
|
|
||||||
let smtp_port = config.bridge.smtp_port;
|
let smtp_port = config.bridge.smtp_port;
|
||||||
tokio::spawn(async move {
|
|
||||||
if let Err(e) = smtp_server::run(smtp_state, smtp_port).await {
|
eprint!("Starting ProtonMail bridge...");
|
||||||
eprintln!("SMTP server error: {e}");
|
proton_bridge::start(config).unwrap_or_else(|e| {
|
||||||
}
|
eprintln!("\nBridge failed to start: {e}");
|
||||||
|
std::process::exit(1);
|
||||||
});
|
});
|
||||||
|
eprintln!(" ready. IMAP :{imap_port} SMTP :{smtp_port} (Ctrl-C to stop)");
|
||||||
|
|
||||||
println!(
|
// Block until Ctrl-C (the servers run in a background thread).
|
||||||
"Bridge ready — IMAP :{}, SMTP :{} (Ctrl-C to stop)",
|
let rt = tokio::runtime::Builder::new_current_thread()
|
||||||
config.bridge.imap_port, config.bridge.smtp_port
|
.enable_all()
|
||||||
);
|
.build()
|
||||||
|
.expect("tokio runtime");
|
||||||
tokio::signal::ctrl_c()
|
rt.block_on(tokio::signal::ctrl_c()).ok();
|
||||||
.await
|
|
||||||
.expect("failed to listen for ctrl-c");
|
|
||||||
println!("Shutting down.");
|
println!("Shutting down.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Key pool setup ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async fn unlock_key_pool(
|
|
||||||
api: &ApiClient<'_>,
|
|
||||||
config: &config::Config,
|
|
||||||
) -> Result<(Vec<crypto::PrivateKey>, Vec<(String, String)>, HashMap<String, String>, HashMap<String, usize>), String> {
|
|
||||||
// Fetch user keys, address keys, and bcrypt salts in parallel.
|
|
||||||
let (user_keys_res, addresses_res, key_salts_res) = tokio::join!(
|
|
||||||
api.get_user_keys(),
|
|
||||||
api.get_addresses(),
|
|
||||||
api.get_key_salts(),
|
|
||||||
);
|
|
||||||
|
|
||||||
let user_keys = user_keys_res.map_err(|e| format!("get_user_keys: {e}"))?;
|
|
||||||
let addresses = addresses_res.map_err(|e| format!("get_addresses: {e}"))?;
|
|
||||||
|
|
||||||
let key_salts = match key_salts_res {
|
|
||||||
Ok(salts) => salts,
|
|
||||||
Err(e) => {
|
|
||||||
if e.contains("403") {
|
|
||||||
auth::Session::delete();
|
|
||||||
return Err(format!(
|
|
||||||
"Session 'locked' scope expired — session.json deleted. \
|
|
||||||
Re-run to enter TOTP and regain 'locked' scope. ({e})"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
eprintln!("get_key_salts: {e} — continuing without salt");
|
|
||||||
vec![]
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let password = config
|
|
||||||
.proton
|
|
||||||
.mailbox_password
|
|
||||||
.as_deref()
|
|
||||||
.unwrap_or(&config.proton.password);
|
|
||||||
|
|
||||||
// Derive the passphrase for a key (bcrypt-31 or raw password fallback).
|
|
||||||
let passphrase_for = |key_id: &str| -> String {
|
|
||||||
let salt = key_salts
|
|
||||||
.iter()
|
|
||||||
.find(|s| s.id == key_id)
|
|
||||||
.and_then(|s| s.key_salt.as_deref())
|
|
||||||
.unwrap_or("");
|
|
||||||
crypto::derive_key_passphrase(password, salt).unwrap_or_else(|_| password.to_string())
|
|
||||||
};
|
|
||||||
|
|
||||||
// Phase 1: unlock user keys.
|
|
||||||
let mut user_private_keys: Vec<crypto::PrivateKey> = Vec::new();
|
|
||||||
for uk in &user_keys {
|
|
||||||
if uk.active != 1 {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let salt = key_salts
|
|
||||||
.iter()
|
|
||||||
.find(|s| s.id == uk.id)
|
|
||||||
.and_then(|s| s.key_salt.as_deref())
|
|
||||||
.unwrap_or("");
|
|
||||||
let pp = crypto::derive_key_passphrase(password, salt)
|
|
||||||
.unwrap_or_else(|_| password.to_string());
|
|
||||||
match crypto::PrivateKey::unlock(&uk.private_key, &pp) {
|
|
||||||
Ok(k) => {
|
|
||||||
println!("user key {} unlocked", &uk.id[..8.min(uk.id.len())]);
|
|
||||||
user_private_keys.push(k);
|
|
||||||
}
|
|
||||||
Err(e) => eprintln!("user key {} FAILED: {e}", &uk.id[..8.min(uk.id.len())]),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 2: unlock address keys via token (newer accounts) or shared passphrase.
|
|
||||||
// Extract the FIRST active key per address as the own public key for draft
|
|
||||||
// self-encryption — this matches addrKR.FirstKey() used by go-proton-api.
|
|
||||||
let mut key_pool: Vec<crypto::PrivateKey> = Vec::new();
|
|
||||||
let mut own_public_keys: HashMap<String, String> = HashMap::new();
|
|
||||||
let mut sender_key_indices: HashMap<String, usize> = HashMap::new();
|
|
||||||
for addr in &addresses {
|
|
||||||
let mut first_active_key_done = false;
|
|
||||||
for ak in &addr.keys {
|
|
||||||
if ak.active != 1 {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let pp = if let Some(token) = &ak.token {
|
|
||||||
let mut token_pp = None;
|
|
||||||
for uk in &user_private_keys {
|
|
||||||
if let Ok(p) = crypto::decrypt_token(token, uk) {
|
|
||||||
token_pp = Some(p);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
token_pp.unwrap_or_else(|| passphrase_for(&ak.id))
|
|
||||||
} else {
|
|
||||||
passphrase_for(&ak.id)
|
|
||||||
};
|
|
||||||
match crypto::PrivateKey::unlock(&ak.private_key, &pp) {
|
|
||||||
Ok(k) => {
|
|
||||||
println!(
|
|
||||||
"address key {} ({}) unlocked primary={}",
|
|
||||||
&ak.id[..8.min(ak.id.len())],
|
|
||||||
addr.email,
|
|
||||||
ak.primary
|
|
||||||
);
|
|
||||||
// Use the first active key per address (matches addrKR.FirstKey()).
|
|
||||||
if !first_active_key_done {
|
|
||||||
match k.public_key_armored() {
|
|
||||||
Ok(pk) => {
|
|
||||||
eprintln!(
|
|
||||||
" [own-key] {} → first-active key primary={}",
|
|
||||||
addr.email, ak.primary
|
|
||||||
);
|
|
||||||
own_public_keys.insert(addr.email.clone(), pk);
|
|
||||||
// Record which key_pool index to use for signing.
|
|
||||||
sender_key_indices.insert(addr.email.clone(), key_pool.len());
|
|
||||||
}
|
|
||||||
Err(e) => eprintln!("extract pub key for {}: {e}", addr.email),
|
|
||||||
}
|
|
||||||
first_active_key_done = true;
|
|
||||||
}
|
|
||||||
key_pool.push(k);
|
|
||||||
}
|
|
||||||
Err(e) => eprintln!(
|
|
||||||
"address key {} ({}): {e}",
|
|
||||||
&ak.id[..8.min(ak.id.len())],
|
|
||||||
addr.email
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Include user keys as fallback.
|
|
||||||
key_pool.extend(user_private_keys);
|
|
||||||
|
|
||||||
// Collect (address_id, email) pairs for use when creating drafts.
|
|
||||||
let sender_addresses: Vec<(String, String)> = addresses
|
|
||||||
.iter()
|
|
||||||
.map(|a| (a.id.clone(), a.email.clone()))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
println!("{} own public key(s) from address keys", own_public_keys.len());
|
|
||||||
Ok((key_pool, sender_addresses, own_public_keys, sender_key_indices))
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,17 @@ use crate::{crypto, SharedState};
|
||||||
pub async fn run(state: SharedState, port: u16) -> Result<(), Box<dyn std::error::Error>> {
|
pub async fn run(state: SharedState, port: u16) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let listener = TcpListener::bind(format!("0.0.0.0:{port}")).await?;
|
let listener = TcpListener::bind(format!("0.0.0.0:{port}")).await?;
|
||||||
println!("SMTP listening on port {port}");
|
println!("SMTP listening on port {port}");
|
||||||
|
run_with_listener(state, listener).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accept connections on an already-bound listener. Used by the embedded bridge
|
||||||
|
/// so ports are guaranteed to be occupied before `start()` returns.
|
||||||
|
pub async fn run_with_listener(
|
||||||
|
state: SharedState,
|
||||||
|
listener: TcpListener,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
loop {
|
loop {
|
||||||
let (socket, addr) = listener.accept().await?;
|
let (socket, _addr) = listener.accept().await?;
|
||||||
eprintln!("SMTP: connection from {addr}");
|
|
||||||
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(socket, state).await {
|
||||||
|
|
@ -54,7 +62,6 @@ async fn handle_connection(
|
||||||
if line.is_empty() && !in_data {
|
if line.is_empty() && !in_data {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
eprintln!("SMTP < {line}");
|
|
||||||
|
|
||||||
// DATA body accumulation (dot-stuffing per RFC 5321 §4.5.2).
|
// DATA body accumulation (dot-stuffing per RFC 5321 §4.5.2).
|
||||||
if in_data {
|
if in_data {
|
||||||
|
|
@ -62,7 +69,6 @@ async fn handle_connection(
|
||||||
in_data = false;
|
in_data = false;
|
||||||
let resp =
|
let resp =
|
||||||
handle_send(&mail_from, &rcpt_to, &data_lines, &state).await;
|
handle_send(&mail_from, &rcpt_to, &data_lines, &state).await;
|
||||||
eprintln!("SMTP > {}", resp.trim_end());
|
|
||||||
writer.write_all(resp.as_bytes()).await?;
|
writer.write_all(resp.as_bytes()).await?;
|
||||||
mail_from.clear();
|
mail_from.clear();
|
||||||
rcpt_to.clear();
|
rcpt_to.clear();
|
||||||
|
|
@ -84,7 +90,6 @@ async fn handle_connection(
|
||||||
// Username line (base64) — we accept any username.
|
// Username line (base64) — we accept any username.
|
||||||
auth_step = 2;
|
auth_step = 2;
|
||||||
let resp = format!("334 {}\r\n", B64.encode("Password:"));
|
let resp = format!("334 {}\r\n", B64.encode("Password:"));
|
||||||
eprintln!("SMTP > 334 (Password:)");
|
|
||||||
writer.write_all(resp.as_bytes()).await?;
|
writer.write_all(resp.as_bytes()).await?;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -104,7 +109,6 @@ async fn handle_connection(
|
||||||
} else {
|
} else {
|
||||||
"535 5.7.8 Authentication credentials invalid\r\n".to_string()
|
"535 5.7.8 Authentication credentials invalid\r\n".to_string()
|
||||||
};
|
};
|
||||||
eprintln!("SMTP > {}", resp.trim_end());
|
|
||||||
writer.write_all(resp.as_bytes()).await?;
|
writer.write_all(resp.as_bytes()).await?;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -177,7 +181,6 @@ async fn handle_connection(
|
||||||
"502 5.5.1 Command not implemented\r\n".to_string()
|
"502 5.5.1 Command not implemented\r\n".to_string()
|
||||||
};
|
};
|
||||||
|
|
||||||
eprintln!("SMTP > {}", resp.trim_end());
|
|
||||||
writer.write_all(resp.as_bytes()).await?;
|
writer.write_all(resp.as_bytes()).await?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
@ -244,12 +247,9 @@ async fn send_to_one(
|
||||||
.cloned()
|
.cloned()
|
||||||
.or_else(|| st.own_public_keys.values().next().cloned())
|
.or_else(|| st.own_public_keys.values().next().cloned())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
eprintln!(" [send] own_pub_key for {canonical_email}: {} chars", opk.len());
|
|
||||||
|
|
||||||
if opk.is_empty() {
|
if opk.is_empty() {
|
||||||
return Err("No cached sender public key — cannot encrypt draft body".to_string());
|
return Err("No cached sender public key — cannot encrypt draft body".to_string());
|
||||||
}
|
}
|
||||||
crypto::log_public_key_ids(&opk, "own-priv-derived");
|
|
||||||
|
|
||||||
// Find + clone the signing key for this address (first-active address key, same as opk).
|
// Find + clone the signing key for this address (first-active address key, same as opk).
|
||||||
// PrivateKey is Clone (SignedSecretKey derives Clone), so we clone once here and
|
// PrivateKey is Clone (SignedSecretKey derives Clone), so we clone once here and
|
||||||
|
|
@ -266,51 +266,23 @@ async fn send_to_one(
|
||||||
let db = match &signing_key {
|
let db = match &signing_key {
|
||||||
Some(sk) => crypto::encrypt_sign_body_armored(plaintext, &opk, sk)
|
Some(sk) => crypto::encrypt_sign_body_armored(plaintext, &opk, sk)
|
||||||
.map_err(|e| format!("sign+encrypt draft body: {e}"))?,
|
.map_err(|e| format!("sign+encrypt draft body: {e}"))?,
|
||||||
None => {
|
None => crypto::encrypt_body_armored(plaintext, &opk)
|
||||||
eprintln!(" [send] no signing key found, falling back to unsigned encrypt");
|
.map_err(|e| format!("encrypt draft body: {e}"))?,
|
||||||
crypto::encrypt_body_armored(plaintext, &opk)
|
|
||||||
.map_err(|e| format!("encrypt draft body: {e}"))?
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
(st.http_client.clone(), st.session.clone(), aid, db, signing_key)
|
(st.http_client.clone(), st.session.clone(), aid, db, signing_key)
|
||||||
};
|
};
|
||||||
let api = ApiClient::new(&http_client, &session);
|
let api = ApiClient::new(&http_client, &session);
|
||||||
|
|
||||||
// Compare own-key IDs from two sources for diagnostics.
|
|
||||||
let server_keys = api.get_public_keys(from).await.unwrap_or_default();
|
|
||||||
eprintln!(" [key-cmp] get_public_keys({from}) returned {} key(s)", server_keys.len());
|
|
||||||
for (i, rk) in server_keys.iter().enumerate() {
|
|
||||||
crypto::log_public_key_ids(&rk.public_key, &format!("server-key[{i}] flags=0x{:x}", rk.flags));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log what key ID is actually embedded in the draft body's PKESK.
|
|
||||||
crypto::dump_message_pkesk(&draft_body);
|
|
||||||
|
|
||||||
// Fetch recipient's public keys to decide send encryption type.
|
// Fetch recipient's public keys to decide send encryption type.
|
||||||
let recipient_keys = api.get_public_keys(to).await.unwrap_or_default();
|
let recipient_keys = api.get_public_keys(to).await.unwrap_or_default();
|
||||||
|
|
||||||
eprintln!(
|
|
||||||
" [send] {} recipient key(s) found for {to}",
|
|
||||||
recipient_keys.len()
|
|
||||||
);
|
|
||||||
// send_type, body, key/session-key, and whether the body is signed (Signature flag).
|
// send_type, body, key/session-key, and whether the body is signed (Signature flag).
|
||||||
let (send_type, body_b64, key_packet_b64, body_signed) = if let Some(rkey) =
|
let (send_type, body_b64, key_packet_b64, body_signed) = if let Some(rkey) =
|
||||||
recipient_keys.iter().find(|k| k.flags & 1 != 0)
|
recipient_keys.iter().find(|k| k.flags & 1 != 0)
|
||||||
{
|
{
|
||||||
// Type 1: ProtonMail internal — encrypt to recipient's key.
|
// Type 1: ProtonMail internal — encrypt to recipient's key.
|
||||||
eprintln!(" [send] using key flags=0x{:x}, first 30 chars of armored key: {:.30}", rkey.flags, rkey.public_key.trim());
|
|
||||||
let (pkesk, enc_body) = crypto::encrypt_body_for_send(plaintext, &rkey.public_key)?;
|
let (pkesk, enc_body) = crypto::encrypt_body_for_send(plaintext, &rkey.public_key)?;
|
||||||
eprintln!(
|
|
||||||
" [send] PKESK {} bytes, first 12: {}",
|
|
||||||
pkesk.len(),
|
|
||||||
pkesk.iter().take(12).map(|b| format!("{b:02x}")).collect::<String>()
|
|
||||||
);
|
|
||||||
eprintln!(
|
|
||||||
" [send] SEIPD {} bytes, first 12: {}",
|
|
||||||
enc_body.len(),
|
|
||||||
enc_body.iter().take(12).map(|b| format!("{b:02x}")).collect::<String>()
|
|
||||||
);
|
|
||||||
(1u32, B64.encode(&enc_body), B64.encode(&pkesk), false)
|
(1u32, B64.encode(&enc_body), B64.encode(&pkesk), false)
|
||||||
} else {
|
} else {
|
||||||
// Type 32: PGP/MIME — detached signature in a separate MIME part so the
|
// Type 32: PGP/MIME — detached signature in a separate MIME part so the
|
||||||
|
|
@ -331,11 +303,9 @@ async fn send_to_one(
|
||||||
let draft_id = api
|
let draft_id = api
|
||||||
.create_draft(&address_id, subject, from, "", to, "", &draft_body)
|
.create_draft(&address_id, subject, from, "", to, "", &draft_body)
|
||||||
.await?;
|
.await?;
|
||||||
eprintln!("SMTP draft created: {draft_id}");
|
|
||||||
|
|
||||||
api.send_draft(&draft_id, to, send_type, &body_b64, &key_packet_b64, body_signed)
|
api.send_draft(&draft_id, to, send_type, &body_b64, &key_packet_b64, body_signed)
|
||||||
.await?;
|
.await?;
|
||||||
eprintln!("SMTP sent to {to} (type {send_type})");
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
115
src/config.rs
115
src/config.rs
|
|
@ -1,10 +1,26 @@
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone, Default, PartialEq)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum Provider {
|
||||||
|
#[default]
|
||||||
|
Imap,
|
||||||
|
Proton,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Clone)]
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
pub imap: ImapConfig,
|
#[serde(default)]
|
||||||
pub smtp: SmtpConfig,
|
pub provider: Provider,
|
||||||
|
|
||||||
|
// Standard IMAP/SMTP providers — absent when provider = "proton"
|
||||||
|
pub imap: Option<ImapConfig>,
|
||||||
|
pub smtp: Option<SmtpConfig>,
|
||||||
|
|
||||||
|
// ProtonMail provider
|
||||||
|
pub proton: Option<ProtonConfig>,
|
||||||
|
pub bridge: Option<BridgeConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How the SMTP connection should be secured.
|
/// How the SMTP connection should be secured.
|
||||||
|
|
@ -39,10 +55,103 @@ pub struct ImapConfig {
|
||||||
pub use_tls: bool,
|
pub use_tls: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Mirror of `proton_bridge::config::ProtonConfig` — used for config parsing
|
||||||
|
/// without requiring the `proton` feature to be active.
|
||||||
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
pub struct ProtonConfig {
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
pub mailbox_password: Option<String>,
|
||||||
|
pub user_key_passphrase: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mirror of `proton_bridge::config::BridgeConfig`.
|
||||||
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
pub struct BridgeConfig {
|
||||||
|
pub imap_port: u16,
|
||||||
|
pub smtp_port: u16,
|
||||||
|
pub local_password: String,
|
||||||
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
pub fn load() -> Result<Self, Box<dyn std::error::Error>> {
|
pub fn load() -> Result<Self, Box<dyn std::error::Error>> {
|
||||||
let content = fs::read_to_string("config.toml")?;
|
let content = fs::read_to_string("config.toml")?;
|
||||||
let config: Config = toml::from_str(&content)?;
|
let config: Config = toml::from_str(&content)?;
|
||||||
Ok(config)
|
Ok(config)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
/// Returns the effective IMAP config regardless of provider.
|
||||||
|
/// For `provider = "imap"` this is the `[imap]` section.
|
||||||
|
/// For `provider = "proton"` this is derived from `[bridge]`.
|
||||||
|
pub fn effective_imap(&self) -> Result<ImapConfig, String> {
|
||||||
|
match self.provider {
|
||||||
|
Provider::Imap => self
|
||||||
|
.imap
|
||||||
|
.clone()
|
||||||
|
.ok_or_else(|| "[imap] section missing from config.toml".to_string()),
|
||||||
|
Provider::Proton => {
|
||||||
|
let b = self
|
||||||
|
.bridge
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| "[bridge] section missing from config.toml".to_string())?;
|
||||||
|
Ok(ImapConfig {
|
||||||
|
host: "127.0.0.1".into(),
|
||||||
|
port: b.imap_port,
|
||||||
|
username: "bridge".into(),
|
||||||
|
password: b.local_password.clone(),
|
||||||
|
use_tls: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the effective SMTP config regardless of provider.
|
||||||
|
pub fn effective_smtp(&self) -> Result<SmtpConfig, String> {
|
||||||
|
match self.provider {
|
||||||
|
Provider::Imap => self
|
||||||
|
.smtp
|
||||||
|
.clone()
|
||||||
|
.ok_or_else(|| "[smtp] section missing from config.toml".to_string()),
|
||||||
|
Provider::Proton => {
|
||||||
|
let b = self
|
||||||
|
.bridge
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| "[bridge] section missing from config.toml".to_string())?;
|
||||||
|
let from = self
|
||||||
|
.proton
|
||||||
|
.as_ref()
|
||||||
|
.map(|p| p.username.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
Ok(SmtpConfig {
|
||||||
|
host: "127.0.0.1".into(),
|
||||||
|
port: b.smtp_port,
|
||||||
|
username: "bridge".into(),
|
||||||
|
password: b.local_password.clone(),
|
||||||
|
tls_mode: TlsMode::None,
|
||||||
|
from,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert to the `proton-bridge` crate's `Config` type.
|
||||||
|
/// Only valid when `provider = "proton"`.
|
||||||
|
#[cfg(feature = "proton")]
|
||||||
|
pub fn as_bridge_config(&self) -> Result<proton_bridge::config::Config, String> {
|
||||||
|
let p = self.proton.as_ref().ok_or("[proton] section missing")?;
|
||||||
|
let b = self.bridge.as_ref().ok_or("[bridge] section missing")?;
|
||||||
|
Ok(proton_bridge::config::Config {
|
||||||
|
proton: proton_bridge::config::ProtonConfig {
|
||||||
|
username: p.username.clone(),
|
||||||
|
password: p.password.clone(),
|
||||||
|
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(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ impl ImapSession {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn connect(config: &Config) -> Result<ImapSession, String> {
|
pub(crate) fn connect(config: &Config) -> Result<ImapSession, String> {
|
||||||
let imap_cfg = &config.imap;
|
let imap_cfg = config.imap.as_ref().expect("imap config present");
|
||||||
|
|
||||||
let addr = (&*imap_cfg.host, imap_cfg.port)
|
let addr = (&*imap_cfg.host, imap_cfg.port)
|
||||||
.to_socket_addrs()
|
.to_socket_addrs()
|
||||||
|
|
|
||||||
|
|
@ -101,7 +101,7 @@ fn worker_loop(
|
||||||
let _ = result_tx.send(WorkerResult::Searched(result));
|
let _ = result_tx.send(WorkerResult::Searched(result));
|
||||||
}
|
}
|
||||||
WorkerCmd::SendEmail { to, subject, body } => {
|
WorkerCmd::SendEmail { to, subject, body } => {
|
||||||
let smtp_cfg = config.smtp.clone();
|
let smtp_cfg = config.smtp.clone().expect("smtp config present");
|
||||||
let tx = result_tx.clone();
|
let tx = result_tx.clone();
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
let result = smtp::send_email(&smtp_cfg, &to, &subject, &body);
|
let result = smtp::send_email(&smtp_cfg, &to, &subject, &body);
|
||||||
|
|
|
||||||
54
src/main.rs
54
src/main.rs
|
|
@ -1,17 +1,59 @@
|
||||||
use std::io;
|
use std::io;
|
||||||
|
use std::process::exit;
|
||||||
|
|
||||||
use crossterm::{
|
use crossterm::{
|
||||||
execute,
|
execute,
|
||||||
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||||
};
|
};
|
||||||
use tuimail::config::Config;
|
use tuimail::config::{Config, Provider};
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
backend::CrosstermBackend,
|
backend::CrosstermBackend,
|
||||||
Terminal,
|
Terminal,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn main() -> io::Result<()> {
|
fn main() -> io::Result<()> {
|
||||||
let config = Config::load().unwrap();
|
let config = Config::load().unwrap_or_else(|e| {
|
||||||
|
eprintln!("Failed to load config.toml: {e}");
|
||||||
|
exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Start bridge if needed (before entering raw mode so output is clean) ──
|
||||||
|
if config.provider == Provider::Proton {
|
||||||
|
#[cfg(feature = "proton")]
|
||||||
|
{
|
||||||
|
let bridge_cfg = config.as_bridge_config().unwrap_or_else(|e| {
|
||||||
|
eprintln!("Bridge config error: {e}");
|
||||||
|
exit(1);
|
||||||
|
});
|
||||||
|
eprint!("Starting ProtonMail bridge...");
|
||||||
|
proton_bridge::start(bridge_cfg).unwrap_or_else(|e| {
|
||||||
|
eprintln!("\nBridge failed to start: {e}");
|
||||||
|
exit(1);
|
||||||
|
});
|
||||||
|
eprintln!(" ready.");
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "proton"))]
|
||||||
|
{
|
||||||
|
eprintln!("tuimail was not compiled with ProtonMail support.");
|
||||||
|
eprintln!("Rebuild with: cargo build --features proton");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Derive effective IMAP/SMTP configs for the chosen provider ──
|
||||||
|
let imap_cfg = config.effective_imap().unwrap_or_else(|e| {
|
||||||
|
eprintln!("{e}");
|
||||||
|
exit(1);
|
||||||
|
});
|
||||||
|
let smtp_cfg = config.effective_smtp().unwrap_or_else(|e| {
|
||||||
|
eprintln!("{e}");
|
||||||
|
exit(1);
|
||||||
|
});
|
||||||
|
let runtime_config = Config {
|
||||||
|
imap: Some(imap_cfg),
|
||||||
|
smtp: Some(smtp_cfg),
|
||||||
|
..config
|
||||||
|
};
|
||||||
|
|
||||||
// Restore terminal on panic
|
// Restore terminal on panic
|
||||||
let default_hook = std::panic::take_hook();
|
let default_hook = std::panic::take_hook();
|
||||||
|
|
@ -21,7 +63,7 @@ fn main() -> io::Result<()> {
|
||||||
default_hook(info);
|
default_hook(info);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// --- Setup terminal ---
|
// ── Setup terminal ──
|
||||||
let mut stdout = io::stdout();
|
let mut stdout = io::stdout();
|
||||||
execute!(stdout, EnterAlternateScreen)?;
|
execute!(stdout, EnterAlternateScreen)?;
|
||||||
enable_raw_mode()?;
|
enable_raw_mode()?;
|
||||||
|
|
@ -29,12 +71,12 @@ fn main() -> io::Result<()> {
|
||||||
let backend = CrosstermBackend::new(stdout);
|
let backend = CrosstermBackend::new(stdout);
|
||||||
let mut terminal = Terminal::new(backend)?;
|
let mut terminal = Terminal::new(backend)?;
|
||||||
|
|
||||||
let result = tuimail::main(&config, &mut terminal);
|
let result = tuimail::main(&runtime_config, &mut terminal);
|
||||||
|
|
||||||
// --- Restore terminal (always, even on error) ---
|
// ── Restore terminal (always, even on error) ──
|
||||||
disable_raw_mode()?;
|
disable_raw_mode()?;
|
||||||
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
|
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
|
||||||
terminal.show_cursor()?;
|
terminal.show_cursor()?;
|
||||||
|
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue