Restructure into Cargo workspace with Tauri desktop GUI (#1)

Split the bridge into a tutabridge-core crate, a Tauri v2 desktop app
(src-tauri) and a React/TS UI (ui), keeping the CLI entrypoint at the
workspace root.

Add encrypted local storage (SQLCipher metadata index + encrypted .eml
files) so mail persists across launches and only the delta is fetched.

Wire the bridge to the Tuta Rust SDK via the tuta-repo submodule
(batch loading, MailDetailsBlob reading, interactive 2FA login).

Implement SMTP sending: build the draft and send it through Tuta's
DraftService/SendDraftService, mirroring the web client (body in
compressedBodyText, non-empty sender/recipient names, populated
SendDraftParameters). Add unit tests for the draft/send payload building.
This commit is contained in:
Anthony M
2026-05-27 14:03:15 +02:00
committed by GitHub
parent 128772bb21
commit e58ee86bed
62 changed files with 15841 additions and 384 deletions
+2
View File
@@ -1 +1,3 @@
/target
/ui/node_modules
/ui/dist
+61
View File
@@ -0,0 +1,61 @@
# TutaBridge
## Architecture
TutaBridge is an IMAP/SMTP bridge for Tuta encrypted email. It exposes a local IMAP+SMTP server that mail clients (Thunderbird, etc.) connect to.
### Core principle: syncer-driven, store-backed
```
Tuta API ←── Syncer (background) ──→ MailStore (in-memory) ←── IMAP server ──→ Thunderbird
←── Tauri UI (stats)
```
- The **syncer** (`sync.rs`) runs independently in a background tokio task. It pulls emails from the Tuta API and populates the `MailStore`.
- The **IMAP server** (`imap/`) ONLY reads from the `MailStore`. It NEVER makes API calls for reads.
- The only IMAP→network calls are **mutations**: marking read/unread (`STORE \Seen`) and trashing (`EXPUNGE`).
### Syncer two-phase cycle
1. **Phase 1 (fast, ~3s)**: Sync mail lists for ALL 6 folders. Store gets populated with mail metadata immediately.
2. **Phase 2 (slow, ~2min)**: Prefetch mail details (body) one by one with rate limiting (150ms/mail). Bodies become available progressively.
3. Wait 60s, repeat.
## Testing
### Unit tests
```bash
cargo test --workspace # 113 bridge tests + SDK tests
```
### Integration test (IMAP)
Requires a running bridge instance (either `cargo run` or `./dev.sh` for GUI).
```bash
python3 scripts/test_imap.py
```
This connects to the local IMAP server and verifies: TLS, auth, folder list, mail count, body fetch, search. It reads the bridge password from `~/Library/Application Support/tutabridge/config.toml` automatically.
### Manual Thunderbird test
1. Start bridge: `./dev.sh` (GUI) or `cargo run` (CLI)
2. Wait for "Pre-fetching N mail details for Inbox" in logs
3. In Thunderbird: IMAP server `127.0.0.1:1143` SSL/TLS, SMTP `127.0.0.1:1025` SSL/TLS
4. Username: your tuta email, Password: bridge_password from config
5. Accept self-signed cert
## Build
```bash
cargo build # CLI + GUI
cargo build -p tutabridge-core # Core library only
```
## SDK branches (tuta-repo submodule)
- `feat/rust-sdk-blob-read` — blob element reading (MailDetailsBlob)
- `feat/rust-sdk-load-multiple` — batch entity loading (load_multiple)
- Locally, `feat/rust-sdk-blob-read` has both merged for development
Generated
+3696 -86
View File
File diff suppressed because it is too large Load Diff
+7 -32
View File
@@ -1,3 +1,8 @@
[workspace]
members = ["crates/bridge", "src-tauri"]
exclude = ["tuta-repo"]
resolver = "2"
[package]
name = "tutabridge"
version = "0.1.0"
@@ -5,40 +10,10 @@ edition = "2021"
rust-version = "1.84.0"
[dependencies]
# Tuta SDK with native HTTP client
tuta-sdk = { path = "tuta-repo/tuta-sdk/rust/sdk", features = ["net"] }
# Async runtime
tutabridge-core = { path = "crates/bridge" }
tokio = { version = "1.43", features = ["full"] }
async-trait = "0.1"
# Logging
tokio-rustls = { version = "0.26", features = ["ring"] }
log = "0.4"
env_logger = "0.11"
# Config
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
toml = "0.8"
# TLS
tokio-rustls = { version = "0.26", features = ["ring"] }
rustls-pemfile = "2"
rcgen = "0.13"
# Crypto primitives for key generation
crypto-primitives = { path = "tuta-repo/tuta-sdk/rust/crypto-primitives" }
# Credentials storage
keyring = { version = "3", features = ["apple-native"] }
# Misc
thiserror = "2.0"
base64 = "0.22"
dirs = "6"
anyhow = "1"
rpassword = "7"
rand_core = "0.6"
[dev-dependencies]
tuta-sdk = { path = "tuta-repo/tuta-sdk/rust/sdk", features = ["net", "logging", "testing"] }
+38
View File
@@ -0,0 +1,38 @@
[package]
name = "tutabridge-core"
version = "0.1.0"
edition = "2021"
rust-version = "1.84.0"
[dependencies]
tuta-sdk = { path = "../../tuta-repo/tuta-sdk/rust/sdk", features = ["net"] }
tokio = { version = "1.43", features = ["full"] }
async-trait = "0.1"
log = "0.4"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
toml = "0.8"
tokio-rustls = { version = "0.26", features = ["ring"] }
rustls-pemfile = "2"
rcgen = "0.13"
crypto-primitives = { path = "../../tuta-repo/tuta-sdk/rust/crypto-primitives" }
keyring = { version = "3", features = ["apple-native"] }
rand = "0.8"
thiserror = "2.0"
base64 = "0.22"
dirs = "6"
anyhow = "1"
rand_core = "0.6"
rusqlite = { version = "0.32", features = ["bundled-sqlcipher"] }
hex = "0.4"
[dev-dependencies]
tuta-sdk = { path = "../../tuta-repo/tuta-sdk/rust/sdk", features = ["net", "logging", "testing"] }
rpassword = "7"
+95
View File
@@ -0,0 +1,95 @@
//! Standalone live test of the new Rust SDK 2FA login flow.
//!
//! Does NOT touch the keyring / saved session. Exercises:
//! initiate_session -> authenticate_with_second_factor_totp
//! -> is_second_factor_pending -> login
//!
//! Run with:
//! TUTA_EMAIL=you@tuta.io TUTA_PASSWORD='...' cargo run -p tutabridge-core --example test_2fa
//! It will prompt for the TOTP code on stdin.
use std::io::{BufRead, Write};
use std::sync::Arc;
use std::time::Duration;
use tutasdk::bindings::rest_client::RestClient;
use tutasdk::bindings::test_file_client::TestFileClient;
use tutasdk::folder_system::MailSetKind;
use tutasdk::net::native_rest_client::NativeRestClient;
use tutasdk::tutanota_constants::SecondFactorType;
use tutasdk::Sdk;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let email = std::env::var("TUTA_EMAIL").unwrap_or_else(|_| "mck1@tuta.io".to_string());
let api_url =
std::env::var("TUTA_API_URL").unwrap_or_else(|_| "https://app.tuta.com".to_string());
let password = match std::env::var("TUTA_PASSWORD") {
Ok(p) => p,
Err(_) => rpassword::prompt_password(format!("Password for {email}: "))?,
};
let rest_client: Arc<dyn RestClient> = Arc::new(NativeRestClient::try_new()?);
let file_client = Arc::new(TestFileClient::default());
let sdk = Sdk::new(api_url, rest_client, file_client);
println!("==> initiate_session for {email}");
let session = sdk.initiate_session(&email, &password).await?;
let access_token = session.credentials.access_token.clone();
println!(
" got credentials, {} pending challenge(s)",
session.challenges.len()
);
if !session.challenges.is_empty() {
for c in &session.challenges {
println!(" challenge: type={} id={:?}", c.r#type, c._id);
}
let totp_type = i64::from(SecondFactorType::Totp);
if !session.challenges.iter().any(|c| c.r#type == totp_type) {
return Err("account has no TOTP factor (only TOTP supported by this test)".into());
}
print!("TOTP code: ");
std::io::stdout().flush()?;
let mut line = String::new();
std::io::stdin().lock().read_line(&mut line)?;
let code: u32 = line.trim().parse().map_err(|_| "invalid TOTP code")?;
println!("==> authenticate_with_second_factor_totp");
sdk.authenticate_with_second_factor_totp(&access_token, code)
.await?;
println!("==> polling is_second_factor_pending");
let mut cleared = false;
for i in 0..30 {
tokio::time::sleep(Duration::from_secs(1)).await;
let pending = sdk.is_second_factor_pending(&access_token).await?;
println!(" poll {i}: pending={pending}");
if !pending {
cleared = true;
break;
}
}
if !cleared {
return Err("2FA still pending after 30s".into());
}
}
println!("==> login");
let logged_in = sdk.login(session.credentials).await?;
println!("==> verifying: load folders");
let mailbox = logged_in.mail_facade().load_user_mailbox().await?;
let folders = logged_in
.mail_facade()
.load_folders_for_mailbox(&mailbox)
.await?;
let inbox = folders
.system_folder_by_type(MailSetKind::Inbox)
.ok_or("no inbox folder found after login")?;
println!(" OK — logged in, inbox folder id={:?}", inbox._id);
println!("\nSUCCESS: full 2FA login flow worked end-to-end.");
Ok(())
}
+203
View File
@@ -0,0 +1,203 @@
use std::sync::Arc;
use tokio::sync::{broadcast, oneshot, watch, RwLock};
use crate::config::{self, Config};
use crate::store::LocalStore;
use crate::sync::{self, MailStore};
use crate::tuta::{self, MailBackend, TwoFactorCallback};
use crate::{imap, smtp, tls};
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
pub enum BridgeStatus {
Stopped,
Starting,
Running,
Error(String),
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct BridgeStats {
pub uptime_secs: Option<u64>,
pub mails_synced: usize,
}
pub struct BridgeHandle {
status: Arc<RwLock<BridgeStatus>>,
shutdown_tx: Option<oneshot::Sender<()>>,
log_tx: broadcast::Sender<String>,
started_at: Option<std::time::Instant>,
store: Option<Arc<MailStore>>,
}
impl BridgeHandle {
pub fn new() -> Self {
let (log_tx, _) = broadcast::channel(256);
Self {
status: Arc::new(RwLock::new(BridgeStatus::Stopped)),
shutdown_tx: None,
log_tx,
started_at: None,
store: None,
}
}
pub fn subscribe_logs(&self) -> broadcast::Receiver<String> {
self.log_tx.subscribe()
}
pub fn log_sender(&self) -> broadcast::Sender<String> {
self.log_tx.clone()
}
pub async fn status(&self) -> BridgeStatus {
self.status.read().await.clone()
}
pub async fn stats(&self) -> BridgeStats {
let count = match &self.store {
Some(store) => store.total_mail_count().await,
None => 0,
};
BridgeStats {
uptime_secs: self.started_at.map(|t| t.elapsed().as_secs()),
mails_synced: count,
}
}
pub async fn start(
&mut self,
config: Config,
password: Option<String>,
totp_callback: Option<TwoFactorCallback>,
) -> Result<(), String> {
{
let current = self.status.read().await;
if *current == BridgeStatus::Running || *current == BridgeStatus::Starting {
return Err("Bridge is already running".into());
}
}
*self.status.write().await = BridgeStatus::Starting;
self.emit_log("TutaBridge starting...");
let tls_acceptor = match tls::load_or_create_tls_acceptor() {
Ok(a) => a,
Err(e) => {
let msg = format!("TLS setup failed: {e}");
*self.status.write().await = BridgeStatus::Error(msg.clone());
return Err(msg);
}
};
self.emit_log("TLS initialized");
self.emit_log(&format!("Authenticating as {}...", config.email));
let session = match tuta::login_with_2fa(&config, password.as_deref(), totp_callback).await {
Ok(s) => s,
Err(e) => {
let msg = format!("Login failed: {e}");
*self.status.write().await = BridgeStatus::Error(msg.clone());
return Err(msg);
}
};
self.emit_log(&format!("Logged in as {}", config.email));
let storage_key = session.derive_storage_key().await.map_err(|e| {
let msg = format!("Storage key derivation failed: {e}");
self.emit_log(&msg);
msg
})?;
self.emit_log("Storage encryption key derived");
let local_store = LocalStore::open(
&config::store_db_path(),
&config::store_mails_dir(),
storage_key,
)
.map_err(|e| {
let msg = format!("Failed to open local store: {e}");
self.emit_log(&msg);
msg
})?;
if !local_store.verify_key() {
self.emit_log("Storage key changed — resetting local cache");
let _ = local_store.reset();
}
let local_store = Arc::new(local_store);
self.emit_log("Local store opened");
let backend: Arc<dyn MailBackend> = Arc::new(session);
let store = MailStore::new();
self.store = Some(store.clone());
let (tx, rx) = oneshot::channel::<()>();
let (shutdown_sync_tx, shutdown_sync_rx) = watch::channel(false);
self.shutdown_tx = Some(tx);
self.started_at = Some(std::time::Instant::now());
let status = self.status.clone();
let log_tx = self.log_tx.clone();
let imap_port = config.imap_port;
let smtp_port = config.smtp_port;
let sync_limit = config.sync_limit;
let pw = config.bridge_password.clone();
tokio::spawn(async move {
let imap_tls = tls_acceptor.clone();
let smtp_tls = tls_acceptor;
let _ = log_tx.send(format!("IMAP listening on 127.0.0.1:{imap_port}"));
let _ = log_tx.send(format!("SMTP listening on 127.0.0.1:{smtp_port}"));
let syncer_handle = tokio::spawn(sync::run_syncer(
store.clone(),
local_store,
backend.clone(),
sync_limit,
shutdown_sync_rx,
));
let imap_handle = tokio::spawn(imap::serve(
imap_port,
store.clone(),
backend.clone(),
imap_tls,
pw.clone(),
));
let smtp_handle = tokio::spawn(smtp::serve(smtp_port, backend.clone(), smtp_tls, pw));
tokio::select! {
_ = rx => {
let _ = log_tx.send("Bridge shutting down...".to_string());
let _ = shutdown_sync_tx.send(true);
}
r = imap_handle => {
if let Err(e) = r {
let _ = log_tx.send(format!("IMAP server error: {e}"));
}
}
r = smtp_handle => {
if let Err(e) = r {
let _ = log_tx.send(format!("SMTP server error: {e}"));
}
}
}
syncer_handle.abort();
*status.write().await = BridgeStatus::Stopped;
let _ = log_tx.send("Bridge stopped".to_string());
});
*self.status.write().await = BridgeStatus::Running;
self.emit_log("Bridge is running");
Ok(())
}
pub async fn stop(&mut self) {
if let Some(tx) = self.shutdown_tx.take() {
let _ = tx.send(());
}
self.started_at = None;
}
fn emit_log(&self, msg: &str) {
let _ = self.log_tx.send(msg.to_string());
}
}
+67 -26
View File
@@ -8,6 +8,14 @@ pub struct Config {
pub smtp_port: u16,
#[serde(default = "default_api_url")]
pub api_url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bridge_password: Option<String>,
#[serde(default = "default_sync_limit")]
pub sync_limit: usize,
}
fn default_sync_limit() -> usize {
500
}
fn default_api_url() -> String {
@@ -21,44 +29,75 @@ impl Default for Config {
imap_port: 1143,
smtp_port: 1025,
api_url: default_api_url(),
bridge_password: None,
sync_limit: default_sync_limit(),
}
}
}
fn config_path() -> PathBuf {
let dir = dirs::config_dir()
fn data_dir() -> PathBuf {
dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("tutabridge");
dir.join("config.toml")
.join("tutabridge")
}
pub fn load_or_create_config() -> Result<Config, Box<dyn std::error::Error>> {
pub fn config_path() -> PathBuf {
data_dir().join("config.toml")
}
pub fn store_db_path() -> PathBuf {
data_dir().join("store.db")
}
pub fn store_mails_dir() -> PathBuf {
data_dir().join("mails")
}
pub fn load_config() -> Result<Option<Config>, Box<dyn std::error::Error>> {
let path = config_path();
let mut cfg = if path.exists() {
if path.exists() {
let content = std::fs::read_to_string(&path)?;
toml::from_str(&content)?
Ok(Some(toml::from_str(&content)?))
} else {
std::fs::create_dir_all(path.parent().unwrap())?;
Config::default()
};
if cfg.email.is_empty() {
use std::io::{BufRead, Write};
print!("Tuta email address: ");
std::io::stdout().flush()?;
let mut email = String::new();
std::io::stdin().lock().read_line(&mut email)?;
let email = email.trim().to_string();
if email.is_empty() {
return Err("Email address is required".into());
}
cfg.email = email;
let content = toml::to_string_pretty(&cfg)?;
std::fs::write(&path, &content)?;
Ok(None)
}
}
Ok(cfg)
pub fn save_config(cfg: &Config) -> Result<(), Box<dyn std::error::Error>> {
let path = config_path();
std::fs::create_dir_all(path.parent().unwrap())?;
let content = toml::to_string_pretty(cfg)?;
std::fs::write(&path, &content)?;
Ok(())
}
pub fn ensure_bridge_password(config: &mut Config) -> Result<String, Box<dyn std::error::Error>> {
if let Some(ref pw) = config.bridge_password {
return Ok(pw.clone());
}
let password = generate_bridge_password();
config.bridge_password = Some(password.clone());
save_config(config)?;
Ok(password)
}
pub fn regenerate_bridge_password(config: &mut Config) -> Result<String, Box<dyn std::error::Error>> {
let password = generate_bridge_password();
config.bridge_password = Some(password.clone());
save_config(config)?;
Ok(password)
}
fn generate_bridge_password() -> String {
use rand::Rng;
const CHARSET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789";
let mut rng = rand::thread_rng();
let mut group = || -> String {
(0..5)
.map(|_| CHARSET[rng.gen_range(0..CHARSET.len())] as char)
.collect()
};
format!("{}-{}-{}-{}", group(), group(), group(), group())
}
#[cfg(test)]
@@ -134,6 +173,8 @@ smtp_port = 1025
imap_port: 2143,
smtp_port: 2025,
api_url: "https://app.tuta.com".to_string(),
bridge_password: None,
sync_limit: 500,
};
let serialized = toml::to_string_pretty(&cfg).unwrap();
let deserialized: Config = toml::from_str(&serialized).unwrap();
@@ -1,29 +1,38 @@
mod session;
use std::sync::Arc;
use std::time::Duration;
use log::{info, error, debug};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::TcpListener;
use tokio::sync::watch;
use tokio_rustls::TlsAcceptor;
use crate::sync::MailStore;
use crate::tuta::MailBackend;
use session::ImapSession;
pub async fn serve(port: u16, tuta: Arc<dyn MailBackend>, tls: TlsAcceptor) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
pub async fn serve(
port: u16,
store: Arc<MailStore>,
backend: Arc<dyn MailBackend>,
tls: TlsAcceptor,
password_hash: Option<String>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let listener = TcpListener::bind(format!("127.0.0.1:{}", port)).await?;
info!("IMAP server listening on 127.0.0.1:{} (TLS)", port);
loop {
let (stream, addr) = listener.accept().await?;
debug!("IMAP connection from {}", addr);
let tuta = tuta.clone();
let store = store.clone();
let backend = backend.clone();
let tls = tls.clone();
let pw_hash = password_hash.clone();
tokio::spawn(async move {
match tls.accept(stream).await {
Ok(tls_stream) => {
if let Err(e) = handle_connection(tls_stream, tuta).await {
if let Err(e) = handle_connection(tls_stream, store, backend, pw_hash).await {
error!("IMAP connection error: {}", e);
}
}
@@ -37,18 +46,21 @@ pub async fn serve(port: u16, tuta: Arc<dyn MailBackend>, tls: TlsAcceptor) -> R
async fn handle_connection(
stream: tokio_rustls::server::TlsStream<tokio::net::TcpStream>,
tuta: Arc<dyn MailBackend>,
store: Arc<MailStore>,
backend: Arc<dyn MailBackend>,
password_hash: Option<String>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let (reader, mut writer) = tokio::io::split(stream);
let mut reader = BufReader::new(reader);
let mut session = ImapSession::new(tuta);
let mut store_watch: watch::Receiver<u64> = store.subscribe();
let mut session = ImapSession::new(store, backend, password_hash);
writer.write_all(b"* OK TutaBridge IMAP4rev1 ready\r\n").await?;
writer.flush().await?;
let mut line = String::new();
loop {
if session.is_idle() {
let poll_interval = Duration::from_secs(30);
line.clear();
tokio::select! {
result = reader.read_line(&mut line) => {
@@ -64,14 +76,16 @@ async fn handle_connection(
debug!("IMAP S: {}", resp.trim_end());
writer.write_all(resp.as_bytes()).await?;
}
writer.flush().await?;
}
}
_ = tokio::time::sleep(poll_interval) => {
_ = store_watch.changed() => {
let updates = session.check_new_mail().await;
for resp in &updates {
debug!("IMAP S (idle): {}", resp.trim_end());
debug!("IMAP S (store update): {}", resp.trim_end());
writer.write_all(resp.as_bytes()).await?;
}
writer.flush().await?;
}
}
continue;
@@ -86,11 +100,16 @@ async fn handle_connection(
let trimmed = line.trim_end();
debug!("IMAP C: {}", trimmed);
let responses = session.handle_command(trimmed).await;
let responses = if session.is_awaiting_auth() {
session.handle_auth_response(trimmed)
} else {
session.handle_command(trimmed).await
};
for resp in &responses {
debug!("IMAP S: {}", resp.trim_end());
writer.write_all(resp.as_bytes()).await?;
}
writer.flush().await?;
if session.is_logout() {
break;
@@ -1,10 +1,11 @@
use std::sync::Arc;
use log::info;
use log::{info, debug};
use tutasdk::entities::generated::tutanota::{Mail, MailDetails};
use tutasdk::folder_system::MailSetKind;
use crate::mail::rfc2822::{extract_headers, format_internal_date};
use crate::mail::mail_to_rfc2822;
use crate::sync::MailStore;
use crate::tuta::MailBackend;
#[derive(Debug, Clone, PartialEq)]
@@ -24,23 +25,33 @@ struct CachedMail {
}
pub struct ImapSession {
tuta: Arc<dyn MailBackend>,
store: Arc<MailStore>,
backend: Arc<dyn MailBackend>,
state: State,
selected_folder: Option<MailSetKind>,
mails: Vec<CachedMail>,
uid_next: u32,
idle_tag: Option<String>,
auth_tag: Option<String>,
password_hash: Option<String>,
}
impl ImapSession {
pub fn new(tuta: Arc<dyn MailBackend>) -> Self {
pub fn new(
store: Arc<MailStore>,
backend: Arc<dyn MailBackend>,
password_hash: Option<String>,
) -> Self {
Self {
tuta,
store,
backend,
state: State::NotAuthenticated,
selected_folder: None,
mails: Vec::new(),
uid_next: 1,
idle_tag: None,
auth_tag: None,
password_hash,
}
}
@@ -52,6 +63,56 @@ impl ImapSession {
self.idle_tag.is_some()
}
pub fn is_awaiting_auth(&self) -> bool {
self.auth_tag.is_some()
}
pub fn handle_auth_response(&mut self, line: &str) -> Vec<String> {
let tag = match self.auth_tag.take() {
Some(t) => t,
None => return vec![],
};
let decoded = match base64::Engine::decode(
&base64::engine::general_purpose::STANDARD,
line.trim(),
) {
Ok(d) => d,
Err(_) => {
return vec![format!(
"{} NO [AUTHENTICATIONFAILED] Invalid base64\r\n",
tag
)];
}
};
// PLAIN format: \0authcid\0password (authzid is empty)
let parts: Vec<&[u8]> = decoded.splitn(3, |&b| b == 0).collect();
let password = if parts.len() == 3 {
String::from_utf8_lossy(parts[2]).to_string()
} else if parts.len() == 2 {
String::from_utf8_lossy(parts[1]).to_string()
} else {
return vec![format!(
"{} NO [AUTHENTICATIONFAILED] Invalid PLAIN data\r\n",
tag
)];
};
if let Some(ref expected) = self.password_hash {
if password != *expected {
return vec![format!(
"{} NO [AUTHENTICATIONFAILED] Invalid credentials\r\n",
tag
)];
}
}
self.state = State::Authenticated;
info!("IMAP client authenticated via AUTHENTICATE PLAIN");
vec![format!("{} OK AUTHENTICATE completed\r\n", tag)]
}
pub fn end_idle(&mut self) -> Vec<String> {
if let Some(tag) = self.idle_tag.take() {
vec![format!("{} OK IDLE terminated\r\n", tag)]
@@ -65,11 +126,10 @@ impl ImapSession {
Some(k) => k,
None => return vec![],
};
let old_count = self.mails.len();
if self.refresh_mails(kind).await.is_ok() {
let new_count = self.mails.len();
if new_count != old_count {
return vec![format!("* {} EXISTS\r\n", new_count)];
let store_count = self.store.folder_count(kind).await;
if store_count != self.mails.len() {
if self.refresh_mails(kind).await.is_ok() {
return vec![format!("* {} EXISTS\r\n", self.mails.len())];
}
}
vec![]
@@ -82,7 +142,15 @@ impl ImapSession {
"CAPABILITY" => self.cmd_capability(&tag),
"NOOP" => vec![format!("{} OK NOOP completed\r\n", tag)],
"LOGOUT" => self.cmd_logout(&tag),
"LOGIN" => self.cmd_login(&tag).await,
"LOGIN" => self.cmd_login(&tag, &args),
"AUTHENTICATE" => {
if args.trim().eq_ignore_ascii_case("PLAIN") {
self.auth_tag = Some(tag.clone());
vec!["+ \r\n".to_string()]
} else {
vec![format!("{} NO Unsupported mechanism\r\n", tag)]
}
}
"LIST" => self.cmd_list(&tag, &args).await,
"LSUB" => self.cmd_list(&tag, &args).await,
"SELECT" => self.cmd_select(&tag, &args).await,
@@ -121,7 +189,16 @@ impl ImapSession {
]
}
async fn cmd_login(&mut self, tag: &str) -> Vec<String> {
fn cmd_login(&mut self, tag: &str, args: &str) -> Vec<String> {
if let Some(ref expected) = self.password_hash {
let (_, password) = parse_login_args(args);
if password != *expected {
return vec![format!(
"{} NO [AUTHENTICATIONFAILED] Invalid credentials\r\n",
tag
)];
}
}
self.state = State::Authenticated;
info!("IMAP client authenticated (bridge session)");
vec![format!("{} OK LOGIN completed\r\n", tag)]
@@ -140,18 +217,10 @@ impl ImapSession {
return responses;
}
match self.load_folder_list().await {
Ok(folders) => {
for (name, flags) in &folders {
responses.push(format!("* LIST ({}) \"/\" \"{}\"\r\n", flags, name));
}
responses.push(format!("{} OK LIST completed\r\n", tag));
}
Err(e) => {
log::error!("Failed to load folders: {}", e);
responses.push(format!("{} NO Failed to load folders\r\n", tag));
}
for (name, flags) in &self.folder_list() {
responses.push(format!("* LIST ({}) \"/\" \"{}\"\r\n", flags, name));
}
responses.push(format!("{} OK LIST completed\r\n", tag));
responses
}
@@ -208,23 +277,16 @@ impl ImapSession {
let folder_name = args.split_whitespace().next().unwrap_or("").trim_matches('"');
let kind = folder_name_to_kind(folder_name);
match self.tuta.load_mail_ids_for_folder(kind).await {
Ok(mails) => {
let count = mails.len();
let unseen = mails.iter().filter(|m| m.unread).count();
vec![
format!(
"* STATUS \"{}\" (MESSAGES {} UNSEEN {} RECENT 0 UIDNEXT {} UIDVALIDITY 1)\r\n",
folder_name, count, unseen, self.uid_next
),
format!("{} OK STATUS completed\r\n", tag),
]
}
Err(e) => {
log::warn!("STATUS failed for {}: {}", folder_name, e);
vec![format!("{} NO Failed to get status\r\n", tag)]
}
}
let stored = self.store.get_folder(kind).await;
let count = stored.len();
let unseen = stored.iter().filter(|m| m.mail.unread).count();
vec![
format!(
"* STATUS \"{}\" (MESSAGES {} UNSEEN {} RECENT 0 UIDNEXT {} UIDVALIDITY 1)\r\n",
folder_name, count, unseen, self.uid_next
),
format!("{} OK STATUS completed\r\n", tag),
]
}
async fn cmd_fetch(&mut self, tag: &str, args: &str, uid_mode: bool) -> Vec<String> {
@@ -243,25 +305,41 @@ impl ImapSession {
}
if self.mails[idx].details.is_none() && needs_body(&items) {
match self.tuta.load_mail_details(&self.mails[idx].mail).await {
Ok(Some(details)) => {
self.mails[idx].details = Some(details);
}
Ok(None) => {
log::warn!("No details available for uid={}", self.mails[idx].uid);
}
Err(e) => {
log::warn!("Failed to load mail details for uid={}: {}", self.mails[idx].uid, e);
}
let elem_id = self.mails[idx]
.mail
._id
.as_ref()
.map(|id| id.element_id.to_string());
let kind = self.selected_folder.unwrap_or(MailSetKind::Inbox);
// Check store — syncer may have loaded details since our snapshot
let from_store = if let Some(ref eid) = elem_id {
self.store.get_details(kind, eid).await
} else {
None
};
if let Some((details, rfc)) = from_store {
self.mails[idx].details = Some(details);
self.mails[idx].rfc2822 = Some(rfc);
} else {
debug!("Details not yet synced for uid={}", self.mails[idx].uid);
}
}
if self.mails[idx].rfc2822.is_none() && needs_body(&items) {
let rfc = mail_to_rfc2822(
&self.mails[idx].mail,
self.mails[idx].details.as_ref(),
);
self.mails[idx].rfc2822 = Some(rfc);
if needs_body(&items) {
if self.mails[idx].details.is_some() && self.mails[idx].rfc2822.is_none() {
let rfc = mail_to_rfc2822(
&self.mails[idx].mail,
self.mails[idx].details.as_ref(),
);
self.mails[idx].rfc2822 = Some(rfc);
} else if self.mails[idx].details.is_none() {
log::warn!(
"No details for uid={}, body will be placeholder",
self.mails[idx].uid,
);
}
}
let cached = &self.mails[idx];
@@ -335,7 +413,7 @@ impl ImapSession {
.collect();
if !mail_ids.is_empty() {
if let Err(e) = self.tuta.set_unread_status(mail_ids, !adding).await {
if let Err(e) = self.backend.set_unread_status(mail_ids, !adding).await {
log::warn!("Failed to update read status on server: {}", e);
}
}
@@ -391,7 +469,7 @@ impl ImapSession {
.collect();
if !deleted_ids.is_empty() {
if let Err(e) = self.tuta.trash_mails(deleted_ids).await {
if let Err(e) = self.backend.trash_mails(deleted_ids).await {
log::warn!("Failed to trash mails: {}", e);
}
}
@@ -414,51 +492,67 @@ impl ImapSession {
}
async fn refresh_mails(&mut self, kind: MailSetKind) -> Result<(), String> {
let mails = self.tuta.load_mail_ids_for_folder(kind).await?;
let stored = self.store.get_folder(kind).await;
let old_uids: std::collections::HashMap<String, u32> = self
.mails
.iter()
.filter_map(|m| {
m.mail._id.as_ref().map(|id| (id.element_id.to_string(), m.uid))
})
.collect();
let old_cache: std::collections::HashMap<String, (u32, Option<MailDetails>, Option<String>)> =
self.mails
.iter()
.filter_map(|m| {
let eid = m.mail._id.as_ref()?.element_id.to_string();
Some((eid, (m.uid, m.details.clone(), m.rfc2822.clone())))
})
.collect();
self.mails.clear();
for mail in mails {
let uid = mail
._id
for sm in stored {
let elem_id = sm.mail._id.as_ref().map(|id| id.element_id.to_string());
let (uid, old_details, old_rfc) = elem_id
.as_ref()
.and_then(|id| old_uids.get(&id.element_id.to_string()).copied())
.and_then(|eid| old_cache.get(eid))
.cloned()
.unwrap_or_else(|| {
let uid = self.uid_next;
self.uid_next += 1;
uid
(uid, None, None)
});
if uid >= self.uid_next {
self.uid_next = uid + 1;
}
let details = sm.details.or(old_details);
let rfc2822 = sm.rfc2822.or(old_rfc).unwrap_or_else(|| {
mail_to_rfc2822(&sm.mail, details.as_ref())
});
self.mails.push(CachedMail {
mail,
details: None,
rfc2822: None,
mail: sm.mail,
details,
rfc2822: Some(rfc2822),
uid,
deleted: false,
});
}
info!("Loaded {} mails for folder {:?}", self.mails.len(), kind);
debug!("Refreshed {} mails for {:?} from store", self.mails.len(), kind);
Ok(())
}
fn resolve_sequence_set(&self, seq_set: &str, uid_mode: bool) -> Vec<usize> {
let max = if uid_mode {
self.mails.iter().map(|m| m.uid).max().unwrap_or(0)
} else {
self.mails.len() as u32
};
let mut result = Vec::new();
for part in seq_set.split(',') {
let part = part.trim();
if let Some((start, end)) = part.split_once(':') {
let s = parse_seq_num(start, self.mails.len() as u32);
let e = parse_seq_num(end, self.mails.len() as u32);
let s = parse_seq_num(start, max);
let e = parse_seq_num(end, max);
let (lo, hi) = if s <= e { (s, e) } else { (e, s) };
for n in lo..=hi {
if let Some(idx) = self.seq_to_index(n, uid_mode) {
@@ -466,7 +560,7 @@ impl ImapSession {
}
}
} else {
let n = parse_seq_num(part, self.mails.len() as u32);
let n = parse_seq_num(part, max);
if let Some(idx) = self.seq_to_index(n, uid_mode) {
result.push(idx);
}
@@ -487,8 +581,15 @@ impl ImapSession {
}
}
async fn load_folder_list(&self) -> Result<Vec<(String, String)>, String> {
self.tuta.load_folder_list().await
fn folder_list(&self) -> Vec<(String, String)> {
vec![
("INBOX".into(), "".into()),
("Sent".into(), "\\Sent".into()),
("Drafts".into(), "\\Drafts".into()),
("Trash".into(), "\\Trash".into()),
("Archive".into(), "\\Archive".into()),
("Spam".into(), "\\Junk".into()),
]
}
}
@@ -660,6 +761,35 @@ fn folder_name_to_kind(name: &str) -> MailSetKind {
}
}
fn parse_login_args(args: &str) -> (String, String) {
let args = args.trim();
let (user, rest) = parse_imap_token(args);
let (pass, _) = parse_imap_token(rest.trim_start());
(user, pass)
}
fn parse_imap_token(s: &str) -> (String, &str) {
if s.starts_with('"') {
let mut result = String::new();
let mut chars = s[1..].char_indices();
while let Some((i, c)) = chars.next() {
match c {
'\\' => {
if let Some((_, escaped)) = chars.next() {
result.push(escaped);
}
}
'"' => return (result, &s[i + 2..]),
_ => result.push(c),
}
}
(result, "")
} else {
let end = s.find(char::is_whitespace).unwrap_or(s.len());
(s[..end].to_string(), if end < s.len() { &s[end..] } else { "" })
}
}
fn parse_command(line: &str) -> (String, String, String) {
let parts: Vec<&str> = line.splitn(3, ' ').collect();
let tag = parts.first().unwrap_or(&"*").to_string();
@@ -1092,6 +1222,7 @@ mod tests {
// =================================================================
use std::sync::Mutex;
use crate::sync::MailStore;
use crate::tuta::MailBackend;
use crate::mail::ParsedMessage;
use tutasdk::entities::generated::tutanota::{Body, Recipients};
@@ -1126,9 +1257,31 @@ mod tests {
}
}
use crate::sync::StoredMail;
async fn populate_store(store: &MailStore, mails: &[Mail]) {
let stored: Vec<StoredMail> = mails
.iter()
.map(|m| StoredMail {
mail: m.clone(),
details: None,
rfc2822: None,
})
.collect();
store.set_folder(MailSetKind::Inbox, stored).await;
}
async fn make_session(backend: Arc<MockBackend>) -> (Arc<MailStore>, ImapSession) {
let store = MailStore::new();
let mails = backend.mails.lock().unwrap().clone();
populate_store(&store, &mails).await;
let session = ImapSession::new(store.clone(), backend, None);
(store, session)
}
#[async_trait::async_trait]
impl MailBackend for MockBackend {
async fn load_mail_ids_for_folder(&self, _kind: MailSetKind) -> Result<Vec<Mail>, String> {
async fn load_mail_ids_for_folder(&self, _kind: MailSetKind, _limit: usize) -> Result<Vec<Mail>, String> {
Ok(self.mails.lock().unwrap().clone())
}
async fn load_mail_details(&self, mail: &Mail) -> Result<Option<MailDetails>, String> {
@@ -1246,14 +1399,20 @@ mod tests {
#[tokio::test]
async fn test_full_login_select_fetch_sequence() {
let backend = Arc::new(MockBackend::with_mails(vec![
make_mail("m1", "First mail", true),
make_mail("m2", "Second mail", false),
]));
backend.add_details("m1", make_details("<p>Body 1</p>"));
backend.add_details("m2", make_details("<p>Body 2</p>"));
let m1 = make_mail("m1", "First mail", true);
let m2 = make_mail("m2", "Second mail", false);
let d1 = make_details("<p>Body 1</p>");
let d2 = make_details("<p>Body 2</p>");
let mut session = ImapSession::new(backend);
let backend = Arc::new(MockBackend::with_mails(vec![m1.clone(), m2.clone()]));
let store = MailStore::new();
let rfc1 = crate::mail::mail_to_rfc2822(&m1, Some(&d1));
let rfc2 = crate::mail::mail_to_rfc2822(&m2, Some(&d2));
store.set_folder(MailSetKind::Inbox, vec![
StoredMail { mail: m1, details: Some(d1), rfc2822: Some(rfc1) },
StoredMail { mail: m2, details: Some(d2), rfc2822: Some(rfc2) },
]).await;
let mut session = ImapSession::new(store, backend, None);
// LOGIN
let resp = session.handle_command("A001 LOGIN user pass").await;
@@ -1291,7 +1450,9 @@ mod tests {
let backend = Arc::new(MockBackend::with_mails(vec![
make_mail("m1", "Unread mail", true),
]));
let mut session = ImapSession::new(backend.clone());
let store = MailStore::new();
populate_store(&store, &backend.mails.lock().unwrap()).await;
let mut session = ImapSession::new(store, backend.clone(), None);
session.handle_command("A001 LOGIN user pass").await;
session.handle_command("A002 SELECT INBOX").await;
@@ -1317,7 +1478,9 @@ mod tests {
make_mail("m2", "Mail 2", false),
make_mail("m3", "Mail 3", false),
]));
let mut session = ImapSession::new(backend.clone());
let store = MailStore::new();
populate_store(&store, &backend.mails.lock().unwrap()).await;
let mut session = ImapSession::new(store, backend.clone(), None);
session.handle_command("A001 LOGIN user pass").await;
session.handle_command("A002 SELECT INBOX").await;
@@ -1350,7 +1513,9 @@ mod tests {
make_mail("m1", "First", false),
make_mail("m2", "Second", false),
]));
let mut session = ImapSession::new(backend.clone());
let store = MailStore::new();
populate_store(&store, &backend.mails.lock().unwrap()).await;
let mut session = ImapSession::new(store.clone(), backend.clone(), None);
session.handle_command("A001 LOGIN user pass").await;
session.handle_command("A002 SELECT INBOX").await;
@@ -1360,11 +1525,12 @@ mod tests {
let uid1_first = extract_uid(&resp[0]);
let uid2_first = extract_uid(&resp[1]);
// Add a new mail and re-SELECT (triggers refresh)
// Add a new mail, update both backend and store, re-SELECT
{
let mut mails = backend.mails.lock().unwrap();
mails.push(make_mail("m3", "Third", true));
}
populate_store(&store, &backend.mails.lock().unwrap()).await;
session.handle_command("A004 SELECT INBOX").await;
// Get UIDs again
@@ -1392,7 +1558,7 @@ mod tests {
let backend = Arc::new(MockBackend::with_mails(vec![
make_mail("m1", "Test", false),
]));
let mut session = ImapSession::new(backend);
let (_store, mut session) = make_session(backend).await;
session.handle_command("A001 LOGIN user pass").await;
session.handle_command("A002 SELECT INBOX").await;
@@ -1415,7 +1581,7 @@ mod tests {
make_mail("m2", "Unread", true),
make_mail("m3", "Also read", false),
]));
let mut session = ImapSession::new(backend);
let (_store, mut session) = make_session(backend).await;
session.handle_command("A001 LOGIN user pass").await;
session.handle_command("A002 SELECT INBOX").await;
@@ -1432,7 +1598,7 @@ mod tests {
make_mail("m1", "Read", false),
make_mail("m2", "Unread", true),
]));
let mut session = ImapSession::new(backend);
let (_store, mut session) = make_session(backend).await;
session.handle_command("A001 LOGIN user pass").await;
session.handle_command("A002 SELECT INBOX").await;
@@ -1448,7 +1614,7 @@ mod tests {
#[tokio::test]
async fn test_not_authenticated_rejects_commands() {
let backend = Arc::new(MockBackend::new());
let mut session = ImapSession::new(backend);
let (_store, mut session) = make_session(backend).await;
let resp = session.handle_command("A001 SELECT INBOX").await;
assert!(resp[0].contains("NO Not authenticated"));
@@ -1460,7 +1626,7 @@ mod tests {
#[tokio::test]
async fn test_no_mailbox_selected_rejects_fetch() {
let backend = Arc::new(MockBackend::new());
let mut session = ImapSession::new(backend);
let (_store, mut session) = make_session(backend).await;
session.handle_command("A001 LOGIN user pass").await;
@@ -1473,7 +1639,7 @@ mod tests {
let backend = Arc::new(MockBackend::with_mails(vec![
make_mail("m1", "Test", false),
]));
let mut session = ImapSession::new(backend);
let (_store, mut session) = make_session(backend).await;
session.handle_command("A001 LOGIN user pass").await;
session.handle_command("A002 SELECT INBOX").await;
@@ -1492,7 +1658,7 @@ mod tests {
make_mail("m3", "Mail 3", false),
make_mail("m4", "Mail 4", false),
]));
let mut session = ImapSession::new(backend);
let (_store, mut session) = make_session(backend).await;
session.handle_command("A001 LOGIN user pass").await;
session.handle_command("A002 SELECT INBOX").await;
@@ -1513,7 +1679,7 @@ mod tests {
#[tokio::test]
async fn test_logout() {
let backend = Arc::new(MockBackend::new());
let mut session = ImapSession::new(backend);
let (_store, mut session) = make_session(backend).await;
let resp = session.handle_command("A001 LOGOUT").await;
assert!(resp.iter().any(|r| r.contains("BYE")));
@@ -1523,7 +1689,7 @@ mod tests {
#[tokio::test]
async fn test_namespace() {
let backend = Arc::new(MockBackend::new());
let mut session = ImapSession::new(backend);
let (_store, mut session) = make_session(backend).await;
let resp = session.handle_command("A001 NAMESPACE").await;
assert!(resp[0].contains("NAMESPACE"));
@@ -1533,7 +1699,7 @@ mod tests {
#[tokio::test]
async fn test_capability() {
let backend = Arc::new(MockBackend::new());
let mut session = ImapSession::new(backend);
let (_store, mut session) = make_session(backend).await;
let resp = session.handle_command("A001 CAPABILITY").await;
assert!(resp[0].contains("IMAP4rev1"));
+9
View File
@@ -0,0 +1,9 @@
pub mod bridge;
pub mod config;
pub mod store;
pub mod sync;
pub mod tuta;
pub mod imap;
pub mod mail;
pub mod smtp;
pub mod tls;
@@ -52,7 +52,7 @@ pub fn mail_to_rfc2822(mail: &Mail, details: Option<&MailDetails>) -> String {
msg.push_str("\r\n");
let body_text = details
.and_then(|d| d.body.text.as_deref().or(d.body.compressedText.as_deref()))
.and_then(|d| d.body.compressedText.as_deref().or(d.body.text.as_deref()))
.unwrap_or("<p>(No body available)</p>");
let encoded = base64_encode_body(body_text.as_bytes());
@@ -1,4 +1,5 @@
use std::sync::Arc;
use base64::Engine;
use log::{info, error, debug};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::TcpListener;
@@ -18,7 +19,20 @@ enum SmtpState {
Quit,
}
pub async fn serve(port: u16, tuta: Arc<dyn MailBackend>, tls: TlsAcceptor) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
#[derive(Debug)]
enum AuthStep {
None,
WaitPlainData,
WaitLoginUser,
WaitLoginPass,
}
pub async fn serve(
port: u16,
tuta: Arc<dyn MailBackend>,
tls: TlsAcceptor,
password_hash: Option<String>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let listener = TcpListener::bind(format!("127.0.0.1:{}", port)).await?;
info!("SMTP server listening on 127.0.0.1:{} (TLS)", port);
@@ -27,11 +41,12 @@ pub async fn serve(port: u16, tuta: Arc<dyn MailBackend>, tls: TlsAcceptor) -> R
debug!("SMTP connection from {}", addr);
let tuta = tuta.clone();
let tls = tls.clone();
let pw_hash = password_hash.clone();
tokio::spawn(async move {
match tls.accept(stream).await {
Ok(tls_stream) => {
if let Err(e) = handle_connection(tls_stream, tuta).await {
if let Err(e) = handle_connection(tls_stream, tuta, pw_hash).await {
error!("SMTP connection error: {}", e);
}
}
@@ -46,6 +61,7 @@ pub async fn serve(port: u16, tuta: Arc<dyn MailBackend>, tls: TlsAcceptor) -> R
async fn handle_connection(
stream: tokio_rustls::server::TlsStream<tokio::net::TcpStream>,
tuta: Arc<dyn MailBackend>,
password_hash: Option<String>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let (reader, mut writer) = tokio::io::split(stream);
let mut reader = BufReader::new(reader);
@@ -56,6 +72,7 @@ async fn handle_connection(
let mut line = String::new();
let mut data_buf = String::new();
let mut in_data = false;
let mut auth_step = AuthStep::None;
loop {
line.clear();
@@ -67,6 +84,32 @@ async fn handle_connection(
let trimmed = line.trim_end();
debug!("SMTP C: {}", trimmed);
if !matches!(auth_step, AuthStep::None) {
let response = match auth_step {
AuthStep::WaitPlainData => {
auth_step = AuthStep::None;
verify_smtp_plain_data(trimmed, &password_hash)
}
AuthStep::WaitLoginUser => {
auth_step = AuthStep::WaitLoginPass;
"334 UGFzc3dvcmQ6\r\n".to_string()
}
AuthStep::WaitLoginPass => {
auth_step = AuthStep::None;
let password = base64::engine::general_purpose::STANDARD
.decode(trimmed.trim())
.ok()
.and_then(|b| String::from_utf8(b).ok())
.unwrap_or_default();
verify_smtp_password(&password, &password_hash)
}
AuthStep::None => unreachable!(),
};
debug!("SMTP S: {}", response.trim_end());
writer.write_all(response.as_bytes()).await?;
continue;
}
if in_data {
if trimmed == "." {
in_data = false;
@@ -122,7 +165,23 @@ async fn handle_connection(
.to_string()
}
"AUTH" => {
"235 2.7.0 Authentication successful\r\n".to_string()
let parts: Vec<&str> = trimmed.splitn(3, ' ').collect();
let auth_type = parts.get(1).unwrap_or(&"").to_uppercase();
match auth_type.as_str() {
"PLAIN" => {
if let Some(data) = parts.get(2) {
verify_smtp_plain_data(data, &password_hash)
} else {
auth_step = AuthStep::WaitPlainData;
"334 \r\n".to_string()
}
}
"LOGIN" => {
auth_step = AuthStep::WaitLoginUser;
"334 VXNlcm5hbWU6\r\n".to_string()
}
_ => "504 Unrecognized auth type\r\n".to_string(),
}
}
"MAIL" => {
let from = extract_address(trimmed);
@@ -200,6 +259,30 @@ fn extract_address(line: &str) -> String {
.to_string()
}
fn verify_smtp_plain_data(data: &str, expected: &Option<String>) -> String {
let decoded = base64::engine::general_purpose::STANDARD
.decode(data.trim())
.unwrap_or_default();
// AUTH PLAIN format: \0username\0password
let parts: Vec<&[u8]> = decoded.splitn(3, |b| *b == 0).collect();
let password = if parts.len() >= 3 {
String::from_utf8_lossy(parts[2]).to_string()
} else {
String::new()
};
verify_smtp_password(&password, expected)
}
fn verify_smtp_password(password: &str, expected: &Option<String>) -> String {
match expected {
Some(expected_pw) if password == expected_pw => {
"235 2.7.0 Authentication successful\r\n".to_string()
}
Some(_) => "535 5.7.8 Authentication failed\r\n".to_string(),
None => "235 2.7.0 Authentication successful\r\n".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
+532
View File
@@ -0,0 +1,532 @@
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use crypto_primitives::aes::Iv;
use crypto_primitives::key::GenericAesKey;
use crypto_primitives::randomizer_facade::RandomizerFacade;
use log::{debug, warn};
use rusqlite::Connection;
use tutasdk::folder_system::MailSetKind;
#[derive(Debug, thiserror::Error)]
pub enum StoreError {
#[error("Database error: {0}")]
Db(#[from] rusqlite::Error),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Crypto error: {0}")]
Crypto(String),
#[error("Serialization error: {0}")]
Serde(#[from] serde_json::Error),
}
pub struct MailMetadata {
pub list_id: String,
pub element_id: String,
pub folder_kind: i64,
pub subject: String,
pub sender_name: String,
pub sender_address: String,
pub received_date_ms: i64,
pub unread: bool,
pub has_details: bool,
pub mail_json: String,
}
pub struct LocalStore {
conn: Mutex<Connection>,
storage_key: GenericAesKey,
mails_dir: PathBuf,
}
impl LocalStore {
pub fn open(
db_path: &Path,
mails_dir: &Path,
storage_key: GenericAesKey,
) -> Result<Self, StoreError> {
std::fs::create_dir_all(mails_dir)?;
if let Some(parent) = db_path.parent() {
std::fs::create_dir_all(parent)?;
}
let conn = Connection::open(db_path)?;
let hex_key = hex::encode(storage_key.as_bytes());
conn.pragma_update(None, "key", format!("x'{hex_key}'"))?;
conn.pragma_update(None, "journal_mode", "WAL")?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS mails (
element_id TEXT PRIMARY KEY,
list_id TEXT NOT NULL,
folder_kind INTEGER NOT NULL,
subject TEXT NOT NULL,
sender_name TEXT NOT NULL DEFAULT '',
sender_address TEXT NOT NULL DEFAULT '',
received_date_ms INTEGER NOT NULL,
unread INTEGER NOT NULL DEFAULT 1,
has_details INTEGER NOT NULL DEFAULT 0,
mail_json TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_mails_folder
ON mails(folder_kind, received_date_ms DESC);
CREATE TABLE IF NOT EXISTS sync_state (
folder_kind INTEGER PRIMARY KEY,
last_sync_ms INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS store_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
INSERT OR IGNORE INTO store_meta(key, value) VALUES ('schema_version', '1');",
)?;
debug!("LocalStore opened at {}", db_path.display());
Ok(Self {
conn: Mutex::new(conn),
storage_key,
mails_dir: mails_dir.to_path_buf(),
})
}
pub fn verify_key(&self) -> bool {
let conn = self.conn.lock().unwrap();
conn.query_row(
"SELECT value FROM store_meta WHERE key = 'schema_version'",
[],
|row| row.get::<_, String>(0),
)
.is_ok()
}
pub fn reset(&self) -> Result<(), StoreError> {
warn!("Resetting local store — all cached data will be deleted");
let conn = self.conn.lock().unwrap();
conn.execute_batch(
"DELETE FROM mails;
DELETE FROM sync_state;
DELETE FROM store_meta;
INSERT INTO store_meta(key, value) VALUES ('schema_version', '1');",
)?;
drop(conn);
if self.mails_dir.exists() {
for entry in std::fs::read_dir(&self.mails_dir)? {
let entry = entry?;
if entry.path().extension().and_then(|e| e.to_str()) == Some("enc") {
let _ = std::fs::remove_file(entry.path());
}
}
}
Ok(())
}
pub fn load_folder_metadata(&self, kind: MailSetKind) -> Result<Vec<MailMetadata>, StoreError> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT element_id, list_id, folder_kind, subject, sender_name, sender_address,
received_date_ms, unread, has_details, mail_json
FROM mails WHERE folder_kind = ?1
ORDER BY received_date_ms DESC",
)?;
let rows = stmt.query_map([kind_to_i64(kind)], |row| {
Ok(MailMetadata {
element_id: row.get(0)?,
list_id: row.get(1)?,
folder_kind: row.get(2)?,
subject: row.get(3)?,
sender_name: row.get(4)?,
sender_address: row.get(5)?,
received_date_ms: row.get(6)?,
unread: row.get::<_, i64>(7)? != 0,
has_details: row.get::<_, i64>(8)? != 0,
mail_json: row.get(9)?,
})
})?;
let mut result = Vec::new();
for row in rows {
result.push(row?);
}
Ok(result)
}
pub fn upsert_mail_metadata(&self, meta: &MailMetadata) -> Result<(), StoreError> {
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT INTO mails (element_id, list_id, folder_kind, subject, sender_name,
sender_address, received_date_ms, unread, has_details, mail_json)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
ON CONFLICT(element_id) DO UPDATE SET
folder_kind = excluded.folder_kind,
subject = excluded.subject,
sender_name = excluded.sender_name,
sender_address = excluded.sender_address,
received_date_ms = excluded.received_date_ms,
unread = excluded.unread,
has_details = excluded.has_details,
mail_json = excluded.mail_json",
rusqlite::params![
meta.element_id,
meta.list_id,
meta.folder_kind,
meta.subject,
meta.sender_name,
meta.sender_address,
meta.received_date_ms,
meta.unread as i64,
meta.has_details as i64,
meta.mail_json,
],
)?;
Ok(())
}
pub fn upsert_mail_metadata_batch(&self, metas: &[MailMetadata]) -> Result<(), StoreError> {
let conn = self.conn.lock().unwrap();
conn.execute_batch("BEGIN IMMEDIATE")?;
{
let mut stmt = conn.prepare_cached(
"INSERT INTO mails (element_id, list_id, folder_kind, subject, sender_name,
sender_address, received_date_ms, unread, has_details, mail_json)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
ON CONFLICT(element_id) DO UPDATE SET
folder_kind = excluded.folder_kind,
subject = excluded.subject,
sender_name = excluded.sender_name,
sender_address = excluded.sender_address,
received_date_ms = excluded.received_date_ms,
unread = excluded.unread,
has_details = CASE WHEN excluded.has_details = 1 THEN 1 ELSE mails.has_details END,
mail_json = excluded.mail_json",
)?;
for meta in metas {
stmt.execute(rusqlite::params![
meta.element_id,
meta.list_id,
meta.folder_kind,
meta.subject,
meta.sender_name,
meta.sender_address,
meta.received_date_ms,
meta.unread as i64,
meta.has_details as i64,
meta.mail_json,
])?;
}
}
conn.execute_batch("COMMIT")?;
Ok(())
}
pub fn delete_mails_not_in(
&self,
kind: MailSetKind,
element_ids: &[&str],
) -> Result<Vec<String>, StoreError> {
let conn = self.conn.lock().unwrap();
let mut deleted = Vec::new();
{
let mut stmt = conn.prepare(
"SELECT element_id FROM mails WHERE folder_kind = ?1",
)?;
let existing: Vec<String> = stmt
.query_map([kind_to_i64(kind)], |row| row.get(0))?
.filter_map(|r| r.ok())
.collect();
let keep: std::collections::HashSet<&str> =
element_ids.iter().copied().collect();
for eid in existing {
if !keep.contains(eid.as_str()) {
deleted.push(eid);
}
}
}
if !deleted.is_empty() {
conn.execute_batch("BEGIN IMMEDIATE")?;
{
let mut stmt =
conn.prepare_cached("DELETE FROM mails WHERE element_id = ?1")?;
for eid in &deleted {
stmt.execute([eid])?;
}
}
conn.execute_batch("COMMIT")?;
}
Ok(deleted)
}
pub fn mark_has_details(&self, element_id: &str) -> Result<(), StoreError> {
let conn = self.conn.lock().unwrap();
conn.execute(
"UPDATE mails SET has_details = 1 WHERE element_id = ?1",
[element_id],
)?;
Ok(())
}
pub fn write_eml(&self, element_id: &str, rfc2822: &str) -> Result<(), StoreError> {
let randomizer = RandomizerFacade::from_core(rand_core::OsRng);
let iv = Iv::generate(&randomizer);
let encrypted = self
.storage_key
.encrypt_data(rfc2822.as_bytes(), iv)
.map_err(|e| StoreError::Crypto(format!("{e:?}")))?;
let final_path = self.mails_dir.join(format!("{element_id}.eml.enc"));
let tmp_path = self.mails_dir.join(format!("{element_id}.eml.enc.tmp"));
std::fs::write(&tmp_path, &encrypted)?;
std::fs::rename(&tmp_path, &final_path)?;
Ok(())
}
pub fn read_eml(&self, element_id: &str) -> Result<Option<String>, StoreError> {
let path = self.mails_dir.join(format!("{element_id}.eml.enc"));
if !path.exists() {
return Ok(None);
}
let encrypted = std::fs::read(&path)?;
let decrypted = self
.storage_key
.decrypt_data(&encrypted)
.map_err(|e| StoreError::Crypto(format!("{e:?}")))?;
String::from_utf8(decrypted)
.map(Some)
.map_err(|e| StoreError::Crypto(format!("Invalid UTF-8: {e}")))
}
pub fn has_eml(&self, element_id: &str) -> bool {
self.mails_dir
.join(format!("{element_id}.eml.enc"))
.exists()
}
pub fn delete_eml(&self, element_id: &str) -> Result<(), StoreError> {
let path = self.mails_dir.join(format!("{element_id}.eml.enc"));
if path.exists() {
std::fs::remove_file(&path)?;
}
Ok(())
}
pub fn mail_count(&self, kind: MailSetKind) -> Result<usize, StoreError> {
let conn = self.conn.lock().unwrap();
let count: i64 = conn.query_row(
"SELECT COUNT(*) FROM mails WHERE folder_kind = ?1",
[kind_to_i64(kind)],
|row| row.get(0),
)?;
Ok(count as usize)
}
pub fn total_count(&self) -> Result<usize, StoreError> {
let conn = self.conn.lock().unwrap();
let count: i64 =
conn.query_row("SELECT COUNT(*) FROM mails", [], |row| row.get(0))?;
Ok(count as usize)
}
}
fn kind_to_i64(kind: MailSetKind) -> i64 {
kind as i64
}
pub fn kind_from_i64(v: i64) -> MailSetKind {
match v {
0 => MailSetKind::Inbox,
1 => MailSetKind::Sent,
2 => MailSetKind::Trash,
3 => MailSetKind::Archive,
4 => MailSetKind::Spam,
5 => MailSetKind::Draft,
_ => MailSetKind::Inbox,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crypto_primitives::aes::{Aes256Key, AES_256_KEY_SIZE};
fn test_key() -> GenericAesKey {
let randomizer = RandomizerFacade::from_core(rand_core::OsRng);
GenericAesKey::Aes256(Aes256Key::generate(&randomizer))
}
fn open_memory_store() -> LocalStore {
let key = test_key();
let tmp_dir = std::env::temp_dir().join(format!("tutabridge_test_{}", rand::random::<u64>()));
std::fs::create_dir_all(&tmp_dir).unwrap();
let db_path = tmp_dir.join("test.db");
let mails_dir = tmp_dir.join("mails");
LocalStore::open(&db_path, &mails_dir, key).unwrap()
}
#[test]
fn test_open_and_verify() {
let store = open_memory_store();
assert!(store.verify_key());
}
#[test]
fn test_upsert_and_load_metadata() {
let store = open_memory_store();
let meta = MailMetadata {
element_id: "abc123".into(),
list_id: "list1".into(),
folder_kind: kind_to_i64(MailSetKind::Inbox),
subject: "Test email".into(),
sender_name: "Alice".into(),
sender_address: "alice@example.com".into(),
received_date_ms: 1700000000000,
unread: true,
has_details: false,
mail_json: "{}".into(),
};
store.upsert_mail_metadata(&meta).unwrap();
let loaded = store.load_folder_metadata(MailSetKind::Inbox).unwrap();
assert_eq!(loaded.len(), 1);
assert_eq!(loaded[0].element_id, "abc123");
assert_eq!(loaded[0].subject, "Test email");
assert!(loaded[0].unread);
assert!(!loaded[0].has_details);
}
#[test]
fn test_batch_upsert() {
let store = open_memory_store();
let metas: Vec<MailMetadata> = (0..100)
.map(|i| MailMetadata {
element_id: format!("mail_{i}"),
list_id: "list1".into(),
folder_kind: kind_to_i64(MailSetKind::Inbox),
subject: format!("Subject {i}"),
sender_name: "Test".into(),
sender_address: "test@test.com".into(),
received_date_ms: 1700000000000 + i,
unread: i % 2 == 0,
has_details: false,
mail_json: "{}".into(),
})
.collect();
store.upsert_mail_metadata_batch(&metas).unwrap();
let loaded = store.load_folder_metadata(MailSetKind::Inbox).unwrap();
assert_eq!(loaded.len(), 100);
assert_eq!(store.mail_count(MailSetKind::Inbox).unwrap(), 100);
assert_eq!(store.total_count().unwrap(), 100);
}
#[test]
fn test_delete_mails_not_in() {
let store = open_memory_store();
let metas: Vec<MailMetadata> = (0..5)
.map(|i| MailMetadata {
element_id: format!("mail_{i}"),
list_id: "list1".into(),
folder_kind: kind_to_i64(MailSetKind::Inbox),
subject: format!("Subject {i}"),
sender_name: "Test".into(),
sender_address: "test@test.com".into(),
received_date_ms: 1700000000000 + i,
unread: false,
has_details: false,
mail_json: "{}".into(),
})
.collect();
store.upsert_mail_metadata_batch(&metas).unwrap();
let keep = vec!["mail_0", "mail_2", "mail_4"];
let deleted = store.delete_mails_not_in(MailSetKind::Inbox, &keep).unwrap();
assert_eq!(deleted.len(), 2);
assert!(deleted.contains(&"mail_1".to_string()));
assert!(deleted.contains(&"mail_3".to_string()));
assert_eq!(store.mail_count(MailSetKind::Inbox).unwrap(), 3);
}
#[test]
fn test_eml_write_read_roundtrip() {
let store = open_memory_store();
let rfc2822 = "From: test@example.com\r\nSubject: Hello\r\n\r\nBody text here";
store.write_eml("test_mail", rfc2822).unwrap();
let read_back = store.read_eml("test_mail").unwrap();
assert_eq!(read_back, Some(rfc2822.to_string()));
}
#[test]
fn test_eml_read_nonexistent() {
let store = open_memory_store();
let result = store.read_eml("nonexistent").unwrap();
assert_eq!(result, None);
}
#[test]
fn test_eml_delete() {
let store = open_memory_store();
store.write_eml("to_delete", "content").unwrap();
assert!(store.read_eml("to_delete").unwrap().is_some());
store.delete_eml("to_delete").unwrap();
assert!(store.read_eml("to_delete").unwrap().is_none());
}
#[test]
fn test_reset() {
let store = open_memory_store();
let meta = MailMetadata {
element_id: "abc".into(),
list_id: "list1".into(),
folder_kind: kind_to_i64(MailSetKind::Inbox),
subject: "Test".into(),
sender_name: "".into(),
sender_address: "test@test.com".into(),
received_date_ms: 0,
unread: false,
has_details: true,
mail_json: "{}".into(),
};
store.upsert_mail_metadata(&meta).unwrap();
store.write_eml("abc", "content").unwrap();
store.reset().unwrap();
assert_eq!(store.total_count().unwrap(), 0);
assert!(store.read_eml("abc").unwrap().is_none());
assert!(store.verify_key());
}
#[test]
fn test_mark_has_details() {
let store = open_memory_store();
let meta = MailMetadata {
element_id: "det".into(),
list_id: "list1".into(),
folder_kind: kind_to_i64(MailSetKind::Inbox),
subject: "Test".into(),
sender_name: "".into(),
sender_address: "t@t.com".into(),
received_date_ms: 0,
unread: false,
has_details: false,
mail_json: "{}".into(),
};
store.upsert_mail_metadata(&meta).unwrap();
let loaded = store.load_folder_metadata(MailSetKind::Inbox).unwrap();
assert!(!loaded[0].has_details);
store.mark_has_details("det").unwrap();
let loaded = store.load_folder_metadata(MailSetKind::Inbox).unwrap();
assert!(loaded[0].has_details);
}
}
+427
View File
@@ -0,0 +1,427 @@
use std::sync::Arc;
use std::time::Duration;
use log::{info, warn, debug};
use tokio::sync::{watch, RwLock};
use tutasdk::entities::generated::tutanota::{Mail, MailDetails};
use tutasdk::folder_system::MailSetKind;
use crate::mail::mail_to_rfc2822;
use crate::store::{LocalStore, MailMetadata};
use crate::tuta::MailBackend;
const FOLDERS: &[MailSetKind] = &[
MailSetKind::Inbox,
MailSetKind::Sent,
MailSetKind::Draft,
MailSetKind::Trash,
MailSetKind::Archive,
MailSetKind::Spam,
];
const INTER_REQUEST_DELAY: Duration = Duration::from_millis(150);
const INTER_FOLDER_DELAY: Duration = Duration::from_millis(300);
const SYNC_INTERVAL: Duration = Duration::from_secs(60);
const MAX_RETRIES: u32 = 3;
#[derive(Clone)]
pub struct StoredMail {
pub mail: Mail,
pub details: Option<MailDetails>,
pub rfc2822: Option<String>,
}
pub struct MailStore {
folders: RwLock<Vec<(MailSetKind, Vec<StoredMail>)>>,
generation: watch::Sender<u64>,
gen_counter: std::sync::atomic::AtomicU64,
}
impl MailStore {
pub fn new() -> Arc<Self> {
let (tx, _) = watch::channel(0u64);
Arc::new(Self {
folders: RwLock::new(Vec::new()),
generation: tx,
gen_counter: std::sync::atomic::AtomicU64::new(0),
})
}
pub fn subscribe(&self) -> watch::Receiver<u64> {
self.generation.subscribe()
}
pub async fn total_mail_count(&self) -> usize {
self.folders.read().await.iter().map(|(_, v)| v.len()).sum()
}
pub async fn folder_count(&self, kind: MailSetKind) -> usize {
self.folders
.read()
.await
.iter()
.find(|(k, _)| *k == kind)
.map(|(_, v)| v.len())
.unwrap_or(0)
}
pub async fn get_folder(&self, kind: MailSetKind) -> Vec<StoredMail> {
self.folders
.read()
.await
.iter()
.find(|(k, _)| *k == kind)
.map(|(_, v)| v.clone())
.unwrap_or_default()
}
pub async fn get_details(&self, kind: MailSetKind, element_id: &str) -> Option<(MailDetails, String)> {
let folders = self.folders.read().await;
let (_, folder) = folders.iter().find(|(k, _)| *k == kind)?;
folder.iter().find_map(|m| {
let eid = m.mail._id.as_ref()?.element_id.to_string();
if eid == element_id {
let details = m.details.clone()?;
let rfc = m.rfc2822.clone()?;
Some((details, rfc))
} else {
None
}
})
}
pub(crate) async fn set_folder(&self, kind: MailSetKind, mails: Vec<StoredMail>) {
let mut folders = self.folders.write().await;
if let Some(entry) = folders.iter_mut().find(|(k, _)| *k == kind) {
entry.1 = mails;
} else {
folders.push((kind, mails));
}
drop(folders);
self.bump_generation();
}
async fn update_mail_details(
&self,
kind: MailSetKind,
element_id: &str,
details: MailDetails,
rfc2822: String,
) {
let mut folders = self.folders.write().await;
if let Some((_, folder)) = folders.iter_mut().find(|(k, _)| *k == kind) {
if let Some(m) = folder.iter_mut().find(|m| {
m.mail
._id
.as_ref()
.map(|id| id.element_id.to_string())
.as_deref()
== Some(element_id)
}) {
m.details = Some(details);
m.rfc2822 = Some(rfc2822);
}
}
drop(folders);
self.bump_generation();
}
fn bump_generation(&self) {
let gen = self.gen_counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
self.generation.send_replace(gen);
}
}
pub async fn run_syncer(
store: Arc<MailStore>,
local_store: Arc<LocalStore>,
backend: Arc<dyn MailBackend>,
sync_limit: usize,
mut shutdown: watch::Receiver<bool>,
) {
info!("Mail syncer started (limit={})", if sync_limit == 0 { "all".to_string() } else { sync_limit.to_string() });
// Phase 0: load cached mails from local store into memory
for &kind in FOLDERS {
match load_cached_folder(&store, &local_store, kind).await {
Ok(count) if count > 0 => {
info!("Loaded {} cached mails for {:?}", count, kind);
}
Ok(_) => {}
Err(e) => warn!("Failed to load cache for {:?}: {}", kind, e),
}
}
let mut cycle_backoff = Duration::ZERO;
loop {
let mut had_error = false;
// Phase 1: sync mail lists for ALL folders (fast, no body loading)
for &kind in FOLDERS {
if *shutdown.borrow() {
info!("Mail syncer shutting down");
return;
}
match sync_folder(&store, &local_store, &*backend, kind, sync_limit).await {
Ok(()) => {}
Err(e) => {
warn!("Sync error for {:?}: {}", kind, e);
had_error = true;
}
}
tokio::time::sleep(INTER_FOLDER_DELAY).await;
}
// Phase 2: prefetch mail details (slow, but all folders are already visible)
for &kind in FOLDERS {
if *shutdown.borrow() {
return;
}
prefetch_details(&store, &local_store, &*backend, kind).await;
}
if had_error {
cycle_backoff = backoff(cycle_backoff);
warn!("Sync cycle had errors, backing off {:?}", cycle_backoff);
} else {
cycle_backoff = Duration::ZERO;
}
let wait = SYNC_INTERVAL + cycle_backoff;
debug!("Next sync in {:?}", wait);
tokio::select! {
_ = tokio::time::sleep(wait) => {}
_ = shutdown.changed() => {
info!("Mail syncer shutting down");
return;
}
}
}
}
async fn load_cached_folder(
store: &MailStore,
local_store: &LocalStore,
kind: MailSetKind,
) -> Result<usize, String> {
let metas = local_store
.load_folder_metadata(kind)
.map_err(|e| format!("{e}"))?;
if metas.is_empty() {
return Ok(0);
}
let mut stored_mails = Vec::with_capacity(metas.len());
for meta in &metas {
let mail: Mail = serde_json::from_str(&meta.mail_json)
.map_err(|e| format!("Bad cached mail {}: {e}", meta.element_id))?;
let rfc2822 = if meta.has_details {
match local_store.read_eml(&meta.element_id) {
Ok(Some(eml)) => Some(eml),
Ok(None) => Some(mail_to_rfc2822(&mail, None)),
Err(e) => {
warn!("Failed to read cached eml {}: {e}", meta.element_id);
Some(mail_to_rfc2822(&mail, None))
}
}
} else {
Some(mail_to_rfc2822(&mail, None))
};
stored_mails.push(StoredMail {
mail,
details: None,
rfc2822,
});
}
let count = stored_mails.len();
store.set_folder(kind, stored_mails).await;
Ok(count)
}
async fn sync_folder(
store: &MailStore,
local_store: &LocalStore,
backend: &dyn MailBackend,
kind: MailSetKind,
limit: usize,
) -> Result<(), String> {
let new_mails = retry(|| backend.load_mail_ids_for_folder(kind, limit)).await?;
let existing = store.get_folder(kind).await;
let existing_map: std::collections::HashMap<String, StoredMail> = existing
.into_iter()
.filter_map(|m| {
let eid = m.mail._id.as_ref()?.element_id.to_string();
Some((eid, m))
})
.collect();
let mut updated = Vec::with_capacity(new_mails.len());
let mut metas_to_upsert = Vec::with_capacity(new_mails.len());
for mail in &new_mails {
let elem_id = mail._id.as_ref().map(|id| id.element_id.to_string());
if let Some(existing) = elem_id.as_ref().and_then(|id| existing_map.get(id)) {
updated.push(StoredMail {
mail: mail.clone(),
details: existing.details.clone(),
rfc2822: existing.rfc2822.clone(),
});
} else {
let rfc2822 = mail_to_rfc2822(mail, None);
updated.push(StoredMail {
mail: mail.clone(),
details: None,
rfc2822: Some(rfc2822),
});
}
metas_to_upsert.push(mail_to_metadata(mail, kind));
}
// Persist metadata to local store
if let Err(e) = local_store.upsert_mail_metadata_batch(&metas_to_upsert) {
warn!("Failed to persist metadata for {:?}: {}", kind, e);
}
// Delete mails removed from server
let current_ids: Vec<&str> = new_mails
.iter()
.filter_map(|m| m._id.as_ref().map(|id| id.element_id.as_str()))
.collect();
match local_store.delete_mails_not_in(kind, &current_ids) {
Ok(deleted) => {
for eid in &deleted {
if let Err(e) = local_store.delete_eml(eid) {
warn!("Failed to delete cached eml {}: {}", eid, e);
}
}
if !deleted.is_empty() {
debug!("Removed {} deleted mails from {:?} cache", deleted.len(), kind);
}
}
Err(e) => warn!("Failed to clean up deleted mails for {:?}: {}", kind, e),
}
store.set_folder(kind, updated).await;
Ok(())
}
async fn prefetch_details(
store: &MailStore,
local_store: &LocalStore,
backend: &dyn MailBackend,
kind: MailSetKind,
) {
let folder = store.get_folder(kind).await;
let api_needed: Vec<Mail> = folder
.into_iter()
.filter(|m| m.details.is_none())
.filter_map(|m| {
let eid = m.mail._id.as_ref()?.element_id.to_string();
if local_store.has_eml(&eid) {
None
} else {
Some(m.mail)
}
})
.collect();
if api_needed.is_empty() {
return;
}
debug!("Pre-fetching {} mail details for {:?}", api_needed.len(), kind);
for mail in &api_needed {
tokio::time::sleep(INTER_REQUEST_DELAY).await;
let result = retry(|| backend.load_mail_details(mail)).await;
match result {
Ok(Some(details)) => {
let rfc2822 = mail_to_rfc2822(mail, Some(&details));
if let Some(id) = mail._id.as_ref() {
let eid = id.element_id.to_string();
if let Err(e) = local_store.write_eml(&eid, &rfc2822) {
warn!("Failed to cache eml {}: {}", eid, e);
}
if let Err(e) = local_store.mark_has_details(&eid) {
warn!("Failed to mark has_details {}: {}", eid, e);
}
store
.update_mail_details(kind, &eid, details, rfc2822)
.await;
}
}
Ok(None) => {
debug!("No details for mail {:?}", mail.subject);
}
Err(e) => {
warn!("Failed to prefetch details for {:?}: {}", mail.subject, e);
}
}
}
}
async fn retry<F, Fut, T>(mut f: F) -> Result<T, String>
where
F: FnMut() -> Fut,
Fut: std::future::Future<Output = Result<T, String>>,
{
let mut delay = Duration::from_secs(1);
for attempt in 0..=MAX_RETRIES {
match f().await {
Ok(v) => return Ok(v),
Err(e) if attempt < MAX_RETRIES => {
warn!("Attempt {} failed: {}, retrying in {:?}", attempt + 1, e, delay);
tokio::time::sleep(delay).await;
delay = backoff(delay);
}
Err(e) => return Err(e),
}
}
unreachable!()
}
fn mail_to_metadata(mail: &Mail, kind: MailSetKind) -> MailMetadata {
let (list_id, element_id) = mail
._id
.as_ref()
.map(|id| (id.list_id.to_string(), id.element_id.to_string()))
.unwrap_or_default();
let mail_json = serde_json::to_string(mail).unwrap_or_default();
MailMetadata {
list_id,
element_id,
folder_kind: kind as i64,
subject: mail.subject.clone(),
sender_name: mail.sender.name.clone(),
sender_address: mail.sender.address.clone(),
received_date_ms: mail.receivedDate.as_millis() as i64,
unread: mail.unread,
has_details: false,
mail_json,
}
}
fn backoff(current: Duration) -> Duration {
let next = if current.is_zero() {
Duration::from_secs(1)
} else {
current * 2
};
next.min(Duration::from_secs(120))
}
+289 -109
View File
@@ -1,13 +1,15 @@
use base64::Engine;
use std::sync::Arc;
use crypto_primitives::aes::{Aes256Key, Iv};
use crypto_primitives::aes::{Aes256Key, Iv, AES_256_KEY_SIZE};
use crypto_primitives::blake3::blake3_kdf;
use crypto_primitives::key::GenericAesKey;
use crypto_primitives::randomizer_facade::RandomizerFacade;
use tutasdk::bindings::file_client::{FileClient, FileClientError};
use tutasdk::bindings::rest_client::RestClient;
use tutasdk::crypto_entity_client::CryptoEntityClient;
use tutasdk::entities::generated::tutanota::{
DraftCreateData, DraftData, DraftRecipient, Mail, MailBox, MailDetails, MailDetailsBlob,
MailSetEntry, SendDraftData,
DraftCreateData, DraftData, DraftRecipient, Mail, MailBox, MailDetails,
MailSetEntry, SendDraftData, SendDraftParameters,
};
use tutasdk::folder_system::{FolderSystem, MailSetKind};
use tutasdk::services::generated::tutanota::{DraftService, SendDraftService};
@@ -19,7 +21,7 @@ use crate::mail::ParsedMessage;
#[async_trait::async_trait]
pub trait MailBackend: Send + Sync {
async fn load_mail_ids_for_folder(&self, kind: MailSetKind) -> Result<Vec<Mail>, String>;
async fn load_mail_ids_for_folder(&self, kind: MailSetKind, limit: usize) -> Result<Vec<Mail>, String>;
async fn load_mail_details(&self, mail: &Mail) -> Result<Option<MailDetails>, String>;
async fn load_folder_list(&self) -> Result<Vec<(String, String)>, String>;
async fn set_unread_status(&self, mail_ids: Vec<IdTupleGenerated>, unread: bool) -> Result<(), String>;
@@ -44,6 +46,22 @@ impl TutaSession {
.await
}
pub async fn derive_storage_key(&self) -> Result<GenericAesKey, String> {
let user_group_id = self.logged_in.get_user_group_id();
let versioned_key = self
.logged_in
.get_current_sym_group_key(&user_group_id)
.await
.map_err(|e| format!("Failed to get user group key: {e}"))?;
let derived = blake3_kdf(
&[versioned_key.object.as_bytes()],
"tutabridge local storage v1",
AES_256_KEY_SIZE,
);
GenericAesKey::from_bytes(&derived)
.map_err(|e| format!("Key derivation error: {e:?}"))
}
fn crypto_client(&self) -> Arc<CryptoEntityClient> {
self.logged_in.mail_facade().get_crypto_entity_client()
}
@@ -51,6 +69,7 @@ impl TutaSession {
async fn load_mail_ids_for_folder_impl(
&self,
folder_kind: MailSetKind,
limit: usize,
) -> Result<Vec<Mail>, ApiCallError> {
let mailbox = self.load_mailbox().await?;
let folders = self.load_folders(&mailbox).await?;
@@ -58,22 +77,38 @@ impl TutaSession {
.system_folder_by_type(folder_kind)
.ok_or_else(|| ApiCallError::internal(format!("Folder {:?} not found", folder_kind)))?;
let count = if limit == 0 { 1000 } else { limit };
let entries_list_id = &folder.entries;
let entries: Vec<MailSetEntry> = self
.crypto_client()
.load_range(
entries_list_id,
&CustomId::default(),
100,
count,
ListLoadDirection::DESC,
)
.await?;
let mut mails = Vec::new();
// Group entries by list_id for batch loading
let mut by_list: std::collections::HashMap<String, Vec<tutasdk::GeneratedId>> =
std::collections::HashMap::new();
for entry in &entries {
match self.crypto_client().load::<Mail, _>(&entry.mail).await {
Ok(mail) => mails.push(mail),
Err(e) => log::warn!("Failed to load mail {:?}: {}", entry.mail, e),
by_list
.entry(entry.mail.list_id.to_string())
.or_default()
.push(entry.mail.element_id.clone());
}
let mut mails = Vec::new();
for (list_id_str, element_ids) in &by_list {
let list_id = tutasdk::GeneratedId(list_id_str.clone());
match self
.crypto_client()
.load_multiple::<Mail>(&list_id, element_ids)
.await
{
Ok(batch) => mails.extend(batch),
Err(e) => log::warn!("Failed to batch load mails from list {}: {}", list_id_str, e),
}
}
@@ -83,10 +118,15 @@ impl TutaSession {
async fn load_mail_details_impl(
&self,
mail: &Mail,
) -> Result<Option<MailDetailsBlob>, ApiCallError> {
) -> Result<Option<MailDetails>, ApiCallError> {
if mail.mailDetails.is_some() {
let blob = self.logged_in.mail_facade().load_mail_details_blob(mail).await?;
Ok(Some(blob))
match self.logged_in.mail_facade().load_mail_details_blob(mail).await {
Ok(details) => Ok(Some(details)),
Err(e) => {
log::error!("Failed to load mail details blob: {e}");
Err(e)
}
}
} else {
Ok(None)
}
@@ -110,54 +150,7 @@ impl TutaSession {
group_key.object.encrypt_key(&session_key, Iv::generate(&randomizer));
let owner_key_version = group_key.version as i64;
let to_recips: Vec<DraftRecipient> = msg
.to
.iter()
.map(|(name, addr)| DraftRecipient {
_id: None,
name: name.clone(),
mailAddress: addr.clone(),
_errors: Default::default(),
})
.collect();
let cc_recips: Vec<DraftRecipient> = msg
.cc
.iter()
.map(|(name, addr)| DraftRecipient {
_id: None,
name: name.clone(),
mailAddress: addr.clone(),
_errors: Default::default(),
})
.collect();
let bcc_recips: Vec<DraftRecipient> = msg
.bcc
.iter()
.map(|(name, addr)| DraftRecipient {
_id: None,
name: name.clone(),
mailAddress: addr.clone(),
_errors: Default::default(),
})
.collect();
let draft_data = DraftData {
_id: None,
subject: msg.subject.clone(),
bodyText: msg.body_html.clone(),
senderMailAddress: self.email.clone(),
senderName: msg.from_name.clone(),
confidential: false,
method: 0,
compressedBodyText: None,
toRecipients: to_recips,
ccRecipients: cc_recips,
bccRecipients: bcc_recips,
addedAttachments: vec![],
removedAttachments: vec![],
replyTos: vec![],
_errors: Default::default(),
};
let draft_data = build_draft_data(msg, &self.email);
let create_data = DraftCreateData {
_format: 0,
@@ -182,24 +175,12 @@ impl TutaSession {
log::info!("Draft created: {:?}", draft_return.draft);
let send_data = SendDraftData {
_format: 0,
language: "en".to_string(),
mailSessionKey: Some(session_key.as_bytes().to_vec()),
bucketEncMailSessionKey: None,
senderNameUnencrypted: None,
plaintext: true,
calendarMethod: false,
sessionEncEncryptionAuthStatus: None,
sendAt: None,
allowUndo: false,
internalRecipientKeyData: vec![],
secureExternalRecipientKeyData: vec![],
attachmentKeyData: vec![],
mail: draft_return.draft,
symEncInternalRecipientKeyData: vec![],
parameters: None,
};
let parameters_id = CustomId(
base64::engine::general_purpose::URL_SAFE_NO_PAD
.encode(randomizer.generate_random_array::<4>()),
);
let send_data =
build_send_draft_data(session_key.as_bytes().to_vec(), draft_return.draft, parameters_id);
let send_return = executor
.post::<SendDraftService>(send_data, ExtraServiceParams::default())
@@ -212,8 +193,8 @@ impl TutaSession {
#[async_trait::async_trait]
impl MailBackend for TutaSession {
async fn load_mail_ids_for_folder(&self, kind: MailSetKind) -> Result<Vec<Mail>, String> {
self.load_mail_ids_for_folder_impl(kind)
async fn load_mail_ids_for_folder(&self, kind: MailSetKind, limit: usize) -> Result<Vec<Mail>, String> {
self.load_mail_ids_for_folder_impl(kind, limit)
.await
.map_err(|e| format!("{e}"))
}
@@ -221,7 +202,6 @@ impl MailBackend for TutaSession {
async fn load_mail_details(&self, mail: &Mail) -> Result<Option<MailDetails>, String> {
self.load_mail_details_impl(mail)
.await
.map(|opt| opt.map(|blob| blob.details))
.map_err(|e| format!("{e}"))
}
@@ -302,7 +282,22 @@ impl FileClient for DiskFileClient {
}
}
pub async fn login(cfg: &Config) -> Result<TutaSession, Box<dyn std::error::Error + Send + Sync>> {
pub enum TwoFactorCallback {
Totp(Box<dyn Fn() -> Result<u32, Box<dyn std::error::Error + Send + Sync>> + Send + Sync>),
}
pub async fn login(
cfg: &Config,
password: &str,
) -> Result<TutaSession, Box<dyn std::error::Error + Send + Sync>> {
login_with_2fa(cfg, Some(password), None).await
}
pub async fn login_with_2fa(
cfg: &Config,
password: Option<&str>,
totp_callback: Option<TwoFactorCallback>,
) -> Result<TutaSession, Box<dyn std::error::Error + Send + Sync>> {
let rest_client: Arc<dyn RestClient> =
Arc::new(tutasdk::net::native_rest_client::NativeRestClient::try_new()?);
let file_client: Arc<dyn FileClient> = Arc::new(DiskFileClient::new());
@@ -324,32 +319,37 @@ pub async fn login(cfg: &Config) -> Result<TutaSession, Box<dyn std::error::Erro
}
}
let password = rpassword_prompt(&cfg.email)?;
let password = password.ok_or("No saved session and no password provided")?;
log::info!("Authenticating with Tuta servers...");
let (session_return, credentials) = sdk
.initiate_session(&cfg.email, &password)
let session = sdk
.initiate_session(&cfg.email, password)
.await
.map_err(|e| {
Box::<dyn std::error::Error + Send + Sync>::from(format!("Login failed: {e}"))
})?;
let credentials = session.credentials;
let access_token = credentials.access_token.clone();
if !session_return.challenges.is_empty() {
for c in &session_return.challenges {
if !session.challenges.is_empty() {
for c in &session.challenges {
log::info!("2FA challenge: type={}, id={:?}", c.r#type, c._id);
}
let has_totp = session_return
let has_totp = session
.challenges
.iter()
.any(|c| c.r#type == 1);
.any(|c| c.r#type == i64::from(tutasdk::tutanota_constants::SecondFactorType::Totp));
if !has_totp {
return Err("Account requires U2F/WebAuthn 2FA which is not supported — only TOTP is supported".into());
}
let totp_code = totp_prompt()?;
sdk.submit_2fa(&session_return.accessToken, totp_code)
let totp_code = match &totp_callback {
Some(TwoFactorCallback::Totp(cb)) => cb()?,
None => return Err("2FA required but no TOTP callback provided".into()),
};
sdk.authenticate_with_second_factor_totp(&access_token, totp_code)
.await
.map_err(|e| {
Box::<dyn std::error::Error + Send + Sync>::from(format!("2FA failed: {e}"))
@@ -359,7 +359,7 @@ pub async fn login(cfg: &Config) -> Result<TutaSession, Box<dyn std::error::Erro
for _ in 0..30 {
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
let pending = sdk
.check_2fa_pending(&session_return.accessToken)
.is_second_factor_pending(&access_token)
.await
.map_err(|e| {
Box::<dyn std::error::Error + Send + Sync>::from(format!("2FA poll failed: {e}"))
@@ -388,6 +388,13 @@ pub async fn login(cfg: &Config) -> Result<TutaSession, Box<dyn std::error::Erro
const KEYRING_SERVICE: &str = "tutabridge";
use std::sync::Mutex;
static CREDENTIALS_CACHE: Mutex<Option<Option<tutasdk::login::Credentials>>> = Mutex::new(None);
pub fn has_saved_session(email: &str) -> bool {
load_credentials(email).is_some()
}
fn save_credentials(email: &str, creds: &tutasdk::login::Credentials) {
let data = serde_json::json!({
"login": creds.login,
@@ -402,6 +409,7 @@ fn save_credentials(email: &str, creds: &tutasdk::login::Credentials) {
tutasdk::login::CredentialType::External => "External",
},
});
match keyring::Entry::new(KEYRING_SERVICE, email) {
Ok(entry) => {
if let Err(e) = entry.set_password(&data.to_string()) {
@@ -412,11 +420,28 @@ fn save_credentials(email: &str, creds: &tutasdk::login::Credentials) {
}
Err(e) => log::warn!("Failed to create keychain entry: {e}"),
}
*CREDENTIALS_CACHE.lock().unwrap() = Some(Some(creds.clone()));
}
fn load_credentials(email: &str) -> Option<tutasdk::login::Credentials> {
let mut cache = CREDENTIALS_CACHE.lock().unwrap();
if let Some(cached) = cache.as_ref() {
return cached.clone();
}
let result = load_credentials_from_keyring(email);
*cache = Some(result.clone());
result
}
fn load_credentials_from_keyring(email: &str) -> Option<tutasdk::login::Credentials> {
let entry = keyring::Entry::new(KEYRING_SERVICE, email).ok()?;
let json_str = entry.get_password().ok()?;
let v: serde_json::Value = serde_json::from_str(&json_str).ok()?;
Some(tutasdk::login::Credentials {
login: v["login"].as_str()?.to_string(),
@@ -435,28 +460,183 @@ fn load_credentials(email: &str) -> Option<tutasdk::login::Credentials> {
}
fn delete_credentials(email: &str) {
if let Ok(entry) = keyring::Entry::new(KEYRING_SERVICE, email) {
let _ = entry.delete_credential();
}
*CREDENTIALS_CACHE.lock().unwrap() = None;
}
fn rpassword_prompt(email: &str) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
use std::io::Write;
print!("Password for {}: ", email);
std::io::stdout().flush()?;
let password = rpassword::read_password()?;
Ok(password)
/// Map SMTP recipients to `DraftRecipient`, falling back to the address when
/// the display name is empty (Tuta's send service rejects empty names).
fn build_draft_recipients(recipients: &[(String, String)]) -> Vec<DraftRecipient> {
recipients
.iter()
.map(|(name, addr)| DraftRecipient {
_id: None,
name: if name.is_empty() {
addr.clone()
} else {
name.clone()
},
mailAddress: addr.clone(),
_errors: Default::default(),
})
.collect()
}
fn totp_prompt() -> Result<u32, Box<dyn std::error::Error + Send + Sync>> {
use std::io::{BufRead, Write};
print!("TOTP code: ");
std::io::stdout().flush()?;
let mut code_str = String::new();
std::io::stdin().lock().read_line(&mut code_str)?;
let code: u32 = code_str
.trim()
.parse()
.map_err(|_| "Invalid TOTP code — must be a number")?;
Ok(code)
/// Build the `DraftData` for a draft creation from a parsed SMTP message.
///
/// Mirrors the web client: the body goes into both `bodyText` and
/// `compressedBodyText`, and empty sender/recipient names fall back to the
/// address (an empty name makes `SendDraftService` fail).
fn build_draft_data(msg: &ParsedMessage, sender_email: &str) -> DraftData {
DraftData {
_id: None,
subject: msg.subject.clone(),
bodyText: msg.body_html.clone(),
senderMailAddress: sender_email.to_string(),
senderName: if msg.from_name.is_empty() {
sender_email.to_string()
} else {
msg.from_name.clone()
},
confidential: false,
method: 0,
compressedBodyText: Some(msg.body_html.clone()),
toRecipients: build_draft_recipients(&msg.to),
ccRecipients: build_draft_recipients(&msg.cc),
bccRecipients: build_draft_recipients(&msg.bcc),
addedAttachments: vec![],
removedAttachments: vec![],
replyTos: vec![],
_errors: Default::default(),
}
}
/// Build the `SendDraftData` for sending a previously created draft.
///
/// The session data is mirrored into the nested `parameters` aggregate (with a
/// generated `_id`), which the current server model reads; `plaintext` is
/// `false` (it reflects the account's plaintext-only setting, not whether the
/// mail is encrypted). Recipient key arrays stay empty for a non-confidential
/// send.
fn build_send_draft_data(
session_key_bytes: Vec<u8>,
draft_id: IdTupleGenerated,
parameters_id: CustomId,
) -> SendDraftData {
SendDraftData {
_format: 0,
language: "en".to_string(),
mailSessionKey: Some(session_key_bytes.clone()),
bucketEncMailSessionKey: None,
senderNameUnencrypted: None,
plaintext: false,
calendarMethod: false,
sessionEncEncryptionAuthStatus: None,
sendAt: None,
allowUndo: false,
internalRecipientKeyData: vec![],
secureExternalRecipientKeyData: vec![],
attachmentKeyData: vec![],
mail: draft_id.clone(),
symEncInternalRecipientKeyData: vec![],
parameters: Some(SendDraftParameters {
_id: Some(parameters_id),
language: "en".to_string(),
mailSessionKey: Some(session_key_bytes),
bucketEncMailSessionKey: None,
senderNameUnencrypted: None,
plaintext: false,
calendarMethod: false,
sessionEncEncryptionAuthStatus: None,
mail: draft_id,
internalRecipientKeyData: vec![],
secureExternalRecipientKeyData: vec![],
symEncInternalRecipientKeyData: vec![],
attachmentKeyData: vec![],
}),
}
}
#[cfg(test)]
mod send_tests {
use super::*;
fn sample_msg() -> ParsedMessage {
ParsedMessage {
from_address: "me@tuta.io".to_string(),
from_name: "Me".to_string(),
to: vec![("Bob".to_string(), "bob@example.com".to_string())],
cc: vec![],
bcc: vec![],
subject: "Hi".to_string(),
body_html: "<p>hello</p>".to_string(),
}
}
#[test]
fn draft_data_puts_body_in_both_fields() {
let d = build_draft_data(&sample_msg(), "me@tuta.io");
assert_eq!(d.bodyText, "<p>hello</p>");
assert_eq!(d.compressedBodyText.as_deref(), Some("<p>hello</p>"));
assert!(!d.confidential);
assert_eq!(d.method, 0);
}
#[test]
fn draft_data_empty_sender_name_falls_back_to_address() {
let mut msg = sample_msg();
msg.from_name = String::new();
let d = build_draft_data(&msg, "me@tuta.io");
assert_eq!(d.senderName, "me@tuta.io");
}
#[test]
fn draft_data_keeps_non_empty_sender_name() {
let d = build_draft_data(&sample_msg(), "me@tuta.io");
assert_eq!(d.senderName, "Me");
}
#[test]
fn recipient_empty_name_falls_back_to_address() {
let recips = build_draft_recipients(&[(String::new(), "x@example.com".to_string())]);
assert_eq!(recips[0].name, "x@example.com");
assert_eq!(recips[0].mailAddress, "x@example.com");
}
#[test]
fn recipient_keeps_non_empty_name() {
let recips = build_draft_recipients(&[("Alice".to_string(), "a@example.com".to_string())]);
assert_eq!(recips[0].name, "Alice");
}
#[test]
fn send_draft_data_mirrors_parameters_and_is_not_plaintext() {
let draft_id = IdTupleGenerated::new(
tutasdk::GeneratedId("list".to_string()),
tutasdk::GeneratedId("elem".to_string()),
);
let pid = CustomId("aggId".to_string());
let sk = vec![1u8, 2, 3, 4];
let sd = build_send_draft_data(sk.clone(), draft_id.clone(), pid.clone());
// top-level
assert!(!sd.plaintext);
assert_eq!(sd.mailSessionKey.as_deref(), Some(sk.as_slice()));
assert!(sd.bucketEncMailSessionKey.is_none());
assert!(sd.internalRecipientKeyData.is_empty());
assert!(sd.secureExternalRecipientKeyData.is_empty());
assert!(sd.symEncInternalRecipientKeyData.is_empty());
assert_eq!(sd.mail, draft_id);
// nested parameters must be populated (None causes a 500 server-side)
let p = sd.parameters.expect("parameters must be set");
assert_eq!(p._id, Some(pid));
assert!(!p.plaintext);
assert_eq!(p.mailSessionKey.as_deref(), Some(sk.as_slice()));
assert_eq!(p.mail, draft_id);
}
}
Executable
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env python3
"""Dev wrapper for cargo tauri dev. Ctrl+C kills all child processes."""
import subprocess, signal, os, sys, time
# Ensure terminal delivers SIGINT on Ctrl+C (zsh disables this in some configs)
os.system("stty isig 2>/dev/null")
proc = subprocess.Popen(
["cargo", "tauri", "dev"] + sys.argv[1:],
start_new_session=True,
)
def kill_all(sig, frame):
try:
os.killpg(proc.pid, signal.SIGKILL)
except ProcessLookupError:
pass
sys.exit(0)
signal.signal(signal.SIGINT, kill_all)
signal.signal(signal.SIGTERM, kill_all)
while proc.poll() is None:
time.sleep(0.5)
+225
View File
@@ -0,0 +1,225 @@
#!/usr/bin/env python3
"""
Integration test for TutaBridge IMAP server.
Connects to the local IMAP bridge and verifies:
1. Authentication works
2. Folder listing works
3. Mail count is correct (store is populated)
4. Mail headers are readable (ENVELOPE)
5. Mail bodies are readable (BODY[])
6. IDLE notifications work
Prerequisites:
- Bridge must be running (cargo run or dev.sh)
- Config must have bridge_password set
Usage:
python3 scripts/test_imap.py [--password BRIDGE_PASSWORD]
"""
import imaplib
import ssl
import sys
import time
import argparse
try:
import tomllib
except ImportError:
import tomli as tomllib
from pathlib import Path
RED = "\033[91m"
GREEN = "\033[92m"
YELLOW = "\033[93m"
RESET = "\033[0m"
def load_config():
config_path = Path.home() / "Library" / "Application Support" / "tutabridge" / "config.toml"
if not config_path.exists():
config_path = Path.home() / ".config" / "tutabridge" / "config.toml"
if not config_path.exists():
return None
with open(config_path, "rb") as f:
return tomllib.load(f)
def ok(msg):
print(f" {GREEN}PASS{RESET} {msg}")
def fail(msg):
print(f" {RED}FAIL{RESET} {msg}")
def warn(msg):
print(f" {YELLOW}WARN{RESET} {msg}")
def test_imap(host, port, email, password):
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
passed = 0
failed = 0
print(f"\nConnecting to {host}:{port}...")
# Test 1: Connection
try:
imap = imaplib.IMAP4_SSL(host, port, ssl_context=ctx)
ok("TLS connection established")
passed += 1
except Exception as e:
fail(f"Connection failed: {e}")
return 0, 1
# Test 2: Authentication
try:
imap.login(email, password)
ok("Authentication successful")
passed += 1
except Exception as e:
fail(f"Authentication failed: {e}")
imap.logout()
return passed, failed + 1
# Test 3: LIST folders
try:
status, folders = imap.list()
assert status == "OK"
folder_names = []
for f in folders:
name = f.decode().split('"')[-2] if f else ""
folder_names.append(name)
expected = {"INBOX", "Sent", "Drafts", "Trash", "Archive", "Spam"}
found = set(folder_names) & expected
if found == expected:
ok(f"LIST returned all {len(expected)} folders")
passed += 1
else:
missing = expected - found
fail(f"LIST missing folders: {missing}")
failed += 1
except Exception as e:
fail(f"LIST failed: {e}")
failed += 1
# Test 4: SELECT INBOX and check mail count
try:
status, data = imap.select("INBOX")
assert status == "OK"
count = int(data[0])
if count > 0:
ok(f"INBOX has {count} messages (store is populated)")
passed += 1
else:
warn(f"INBOX has 0 messages - syncer may still be loading")
failed += 1
except Exception as e:
fail(f"SELECT INBOX failed: {e}")
failed += 1
# Test 5: FETCH headers (FLAGS + ENVELOPE-like)
if count > 0:
try:
uid = str(min(count, 1))
status, data = imap.fetch(uid, "(FLAGS UID)")
assert status == "OK"
resp = data[0].decode() if isinstance(data[0], bytes) else str(data[0])
assert "FLAGS" in resp
ok(f"FETCH FLAGS works (msg 1)")
passed += 1
except Exception as e:
fail(f"FETCH FLAGS failed: {e}")
failed += 1
# Test 6: FETCH body
try:
status, data = imap.fetch(uid, "(BODY.PEEK[])")
assert status == "OK"
if data[0] is None:
warn("BODY[] returned None - details not yet synced (expected during prefetch)")
failed += 1
else:
body = data[0][1] if isinstance(data[0], tuple) else data[0]
body_str = body.decode("utf-8", errors="replace") if isinstance(body, bytes) else str(body)
if len(body_str) > 50:
ok(f"FETCH BODY[] returned {len(body_str)} bytes")
passed += 1
elif len(body_str) > 0:
warn(f"FETCH BODY[] returned only {len(body_str)} bytes (details may not be synced yet)")
failed += 1
else:
fail("FETCH BODY[] returned empty")
failed += 1
except Exception as e:
fail(f"FETCH BODY[] failed: {e}")
failed += 1
# Test 7: STATUS on other folders
for folder in ["Sent", "Drafts", "Trash"]:
try:
status, data = imap.status(folder, "(MESSAGES UNSEEN)")
assert status == "OK"
resp = data[0].decode() if isinstance(data[0], bytes) else str(data[0])
ok(f"STATUS {folder}: {resp.strip()}")
passed += 1
except Exception as e:
fail(f"STATUS {folder} failed: {e}")
failed += 1
# Test 8: SEARCH UNSEEN
try:
status, data = imap.search(None, "UNSEEN")
assert status == "OK"
unseen_ids = data[0].decode().split() if data[0] else []
ok(f"SEARCH UNSEEN found {len(unseen_ids)} messages")
passed += 1
except Exception as e:
fail(f"SEARCH UNSEEN failed: {e}")
failed += 1
# Cleanup
try:
imap.logout()
except:
pass
return passed, failed
def main():
parser = argparse.ArgumentParser(description="Test TutaBridge IMAP server")
parser.add_argument("--password", help="Bridge password (reads from config if not provided)")
parser.add_argument("--host", default="127.0.0.1", help="IMAP host")
parser.add_argument("--port", type=int, help="IMAP port (reads from config if not provided)")
parser.add_argument("--email", help="Email address (reads from config if not provided)")
args = parser.parse_args()
config = load_config()
email = args.email or (config and config.get("email")) or "user@tuta.io"
password = args.password or (config and config.get("bridge_password"))
port = args.port or (config and config.get("imap_port")) or 1143
if not password:
print(f"{RED}No bridge password found. Pass --password or set bridge_password in config.{RESET}")
sys.exit(1)
print(f"TutaBridge IMAP Integration Test")
print(f"================================")
print(f"Host: {args.host}:{port}")
print(f"Email: {email}")
passed, failed = test_imap(args.host, port, email, password)
print(f"\n{'=' * 40}")
print(f"Results: {GREEN}{passed} passed{RESET}, {RED if failed else ''}{failed} failed{RESET}")
if failed > 0:
sys.exit(1)
print(f"\n{GREEN}All tests passed!{RESET}")
if __name__ == "__main__":
main()
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "tutabridge-gui"
version = "0.1.0"
edition = "2021"
rust-version = "1.84.0"
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tutabridge-core = { path = "../crates/bridge" }
tauri = { version = "2", features = [] }
tauri-plugin-shell = "2"
tokio = { version = "1.43", features = ["full"] }
tokio-rustls = { version = "0.26", features = ["ring"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
log = "0.4"
env_logger = "0.11"
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
+13
View File
@@ -0,0 +1,13 @@
{
"$schema": "https://raw.githubusercontent.com/niceda/tauri/dev/crates/tauri-utils/schema.json",
"identifier": "default",
"description": "Default capabilities for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"shell:allow-open",
"core:event:default",
"core:event:allow-listen",
"core:event:allow-emit"
]
}
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
{"default":{"identifier":"default","description":"Default capabilities for the main window","local":true,"windows":["main"],"permissions":["core:default","shell:allow-open","core:event:default","core:event:allow-listen","core:event:allow-emit"]}}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 360 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 856 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 B

+80
View File
@@ -0,0 +1,80 @@
use std::sync::Arc;
use tauri::State;
use tokio::sync::Mutex;
use tutabridge_core::bridge::{BridgeHandle, BridgeStats, BridgeStatus};
use tutabridge_core::config::{self, Config};
use tutabridge_core::tuta;
pub type BridgeState = Arc<Mutex<BridgeHandle>>;
#[tauri::command]
pub async fn get_config() -> Result<Config, String> {
match config::load_config() {
Ok(Some(cfg)) => Ok(cfg),
Ok(None) => Ok(Config::default()),
Err(e) => Err(format!("Failed to load config: {e}")),
}
}
#[tauri::command]
pub async fn save_config(config: Config) -> Result<(), String> {
config::save_config(&config).map_err(|e| format!("Failed to save config: {e}"))
}
#[tauri::command]
pub async fn has_saved_session() -> Result<bool, String> {
let cfg = match config::load_config() {
Ok(Some(cfg)) if !cfg.email.is_empty() => cfg,
_ => return Ok(false),
};
Ok(tuta::has_saved_session(&cfg.email))
}
#[tauri::command]
pub async fn start_bridge(
password: Option<String>,
state: State<'_, BridgeState>,
) -> Result<(), String> {
let mut cfg = match config::load_config() {
Ok(Some(cfg)) if !cfg.email.is_empty() => cfg,
_ => return Err("No config found — save config first".into()),
};
config::ensure_bridge_password(&mut cfg).map_err(|e| format!("Bridge password setup failed: {e}"))?;
let mut handle = state.lock().await;
handle.start(cfg, password, None).await
}
#[tauri::command]
pub async fn stop_bridge(state: State<'_, BridgeState>) -> Result<(), String> {
let mut handle = state.lock().await;
handle.stop().await;
Ok(())
}
#[tauri::command]
pub async fn get_status(state: State<'_, BridgeState>) -> Result<BridgeStatus, String> {
let handle = state.lock().await;
Ok(handle.status().await)
}
#[tauri::command]
pub async fn get_stats(state: State<'_, BridgeState>) -> Result<BridgeStats, String> {
let handle = state.lock().await;
Ok(handle.stats().await)
}
#[tauri::command]
pub async fn get_bridge_password() -> Result<Option<String>, String> {
let cfg = config::load_config().map_err(|e| e.to_string())?;
Ok(cfg.and_then(|c| c.bridge_password))
}
#[tauri::command]
pub async fn regenerate_bridge_password() -> Result<String, String> {
let mut cfg = config::load_config()
.map_err(|e| e.to_string())?
.ok_or("No config found")?;
config::regenerate_bridge_password(&mut cfg).map_err(|e| e.to_string())
}
+94
View File
@@ -0,0 +1,94 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod commands;
use std::sync::Arc;
use commands::BridgeState;
use tauri::Manager;
use tokio::sync::Mutex;
use tutabridge_core::bridge::BridgeHandle;
fn main() {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("debug"))
.init();
tokio_rustls::rustls::crypto::ring::default_provider()
.install_default()
.expect("Failed to install TLS crypto provider");
let handle = BridgeHandle::new();
let log_rx = handle.subscribe_logs();
let shared = Arc::new(Mutex::new(handle));
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.manage(shared as BridgeState)
.invoke_handler(tauri::generate_handler![
commands::get_config,
commands::save_config,
commands::has_saved_session,
commands::start_bridge,
commands::stop_bridge,
commands::get_status,
commands::get_stats,
commands::get_bridge_password,
commands::regenerate_bridge_password,
])
.setup(|app| {
let app_handle = app.handle().clone();
tauri::async_runtime::spawn(async move {
stream_logs(app_handle, log_rx).await;
});
let state = app.state::<BridgeState>().inner().clone();
tauri::async_runtime::spawn(async move {
auto_start(state).await;
});
Ok(())
})
.run(tauri::generate_context!())
.expect("error running TutaBridge");
}
async fn auto_start(state: Arc<Mutex<BridgeHandle>>) {
use tutabridge_core::config;
use tutabridge_core::tuta;
let mut cfg = match config::load_config() {
Ok(Some(cfg)) if !cfg.email.is_empty() => cfg,
_ => return,
};
// Ensure bridge password exists (so the UI can display it)
if let Err(e) = config::ensure_bridge_password(&mut cfg) {
log::warn!("Bridge password setup failed: {e}");
}
if !tuta::has_saved_session(&cfg.email) {
return;
}
let mut handle = state.lock().await;
if let Err(e) = handle.start(cfg, None, None).await {
log::warn!("Auto-start failed: {e}");
}
}
async fn stream_logs(
app: tauri::AppHandle,
mut rx: tokio::sync::broadcast::Receiver<String>,
) {
use tauri::Emitter;
loop {
match rx.recv().await {
Ok(line) => {
let _ = app.emit("bridge://log", &line);
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
let _ = app.emit("bridge://log", &format!("... skipped {n} log lines"));
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}
}
}
+35
View File
@@ -0,0 +1,35 @@
{
"$schema": "https://raw.githubusercontent.com/niceda/tauri/dev/crates/tauri-config-schema/schema.json",
"productName": "TutaBridge",
"version": "0.1.0",
"identifier": "com.tutabridge.app",
"build": {
"frontendDist": "../ui/dist",
"devUrl": "http://localhost:1420",
"beforeDevCommand": "cd ../ui && npm run dev",
"beforeBuildCommand": "cd ../ui && npm run build"
},
"app": {
"windows": [
{
"title": "TutaBridge",
"width": 700,
"height": 520,
"resizable": true,
"center": true
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png"
]
}
}
+96 -15
View File
@@ -1,12 +1,6 @@
mod config;
mod tuta;
mod imap;
mod mail;
mod smtp;
mod tls;
use std::sync::Arc;
use log::info;
use tutabridge_core::{config, store::LocalStore, sync, tls, tuta, imap, smtp};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
@@ -16,7 +10,29 @@ async fn main() -> anyhow::Result<()> {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
let cfg = config::load_or_create_config().map_err(|e| anyhow::anyhow!("{e}"))?;
let mut cfg = match config::load_config().map_err(|e| anyhow::anyhow!("{e}"))? {
Some(cfg) if !cfg.email.is_empty() => cfg,
_ => {
use std::io::{BufRead, Write};
print!("Tuta email address: ");
std::io::stdout().flush()?;
let mut email = String::new();
std::io::stdin().lock().read_line(&mut email)?;
let email = email.trim().to_string();
if email.is_empty() {
anyhow::bail!("Email address is required");
}
let cfg = config::Config {
email,
..Default::default()
};
config::save_config(&cfg).map_err(|e| anyhow::anyhow!("{e}"))?;
cfg
}
};
let bridge_password = config::ensure_bridge_password(&mut cfg)
.map_err(|e| anyhow::anyhow!("Bridge password setup failed: {e}"))?;
info!("TutaBridge starting...");
let tls_acceptor = tls::load_or_create_tls_acceptor()
@@ -26,26 +42,91 @@ async fn main() -> anyhow::Result<()> {
info!("IMAP will listen on 127.0.0.1:{}", cfg.imap_port);
info!("SMTP will listen on 127.0.0.1:{}", cfg.smtp_port);
let session = tuta::login(&cfg).await.map_err(|e| anyhow::anyhow!("{e}"))?;
let session: Arc<dyn tuta::MailBackend> = Arc::new(session);
let totp_cb = tuta::TwoFactorCallback::Totp(Box::new(|| {
use std::io::{BufRead, Write};
print!("TOTP code: ");
std::io::stdout().flush()?;
let mut code_str = String::new();
std::io::stdin().lock().read_line(&mut code_str)?;
let code: u32 = code_str
.trim()
.parse()
.map_err(|_| "Invalid TOTP code — must be a number")?;
Ok(code)
}));
// Try keyring session first, only prompt for password if needed
let session = match tuta::login_with_2fa(&cfg, None, Some(totp_cb)).await {
Ok(s) => s,
Err(_) => {
let password = rpassword::prompt_password(format!("Password for {}: ", cfg.email))?;
let totp_cb2 = tuta::TwoFactorCallback::Totp(Box::new(|| {
use std::io::{BufRead, Write};
print!("TOTP code: ");
std::io::stdout().flush()?;
let mut code_str = String::new();
std::io::stdin().lock().read_line(&mut code_str)?;
let code: u32 = code_str
.trim()
.parse()
.map_err(|_| "Invalid TOTP code — must be a number")?;
Ok(code)
}));
tuta::login_with_2fa(&cfg, Some(&password), Some(totp_cb2))
.await
.map_err(|e| anyhow::anyhow!("{e}"))?
}
};
info!("Logged in as {}", cfg.email);
let imap_session = session.clone();
let smtp_session = session.clone();
let storage_key = session.derive_storage_key().await
.map_err(|e| anyhow::anyhow!("{e}"))?;
info!("Storage encryption key derived");
let local_store = LocalStore::open(
&config::store_db_path(),
&config::store_mails_dir(),
storage_key,
).map_err(|e| anyhow::anyhow!("{e}"))?;
if !local_store.verify_key() {
info!("Storage key changed — resetting local cache");
let _ = local_store.reset();
}
let local_store = Arc::new(local_store);
info!("Local store opened");
let backend: Arc<dyn tuta::MailBackend> = Arc::new(session);
let store = sync::MailStore::new();
let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
let imap_tls = tls_acceptor.clone();
let smtp_tls = tls_acceptor;
let imap_handle = tokio::spawn(imap::serve(cfg.imap_port, imap_session, imap_tls));
let smtp_handle = tokio::spawn(smtp::serve(cfg.smtp_port, smtp_session, smtp_tls));
let pw = cfg.bridge_password.clone();
let syncer_handle = tokio::spawn(sync::run_syncer(
store.clone(), local_store, backend.clone(), cfg.sync_limit, shutdown_rx,
));
let imap_handle = tokio::spawn(imap::serve(
cfg.imap_port, store.clone(), backend.clone(), imap_tls, pw.clone(),
));
let smtp_handle = tokio::spawn(smtp::serve(cfg.smtp_port, backend.clone(), smtp_tls, pw));
info!("Bridge is running. Configure Thunderbird with:");
info!(" IMAP server: 127.0.0.1:{} (SSL/TLS)", cfg.imap_port);
info!(" SMTP server: 127.0.0.1:{} (SSL/TLS)", cfg.smtp_port);
info!(" Username: {}", cfg.email);
info!(" Password: (any password — bridge handles auth)");
info!(" Password: {}", bridge_password);
info!(" Accept the self-signed certificate when prompted");
tokio::select! {
_ = tokio::signal::ctrl_c() => {
info!("Shutting down...");
let _ = shutdown_tx.send(true);
syncer_handle.abort();
Ok(())
}
r = imap_handle => r.map_err(|e| anyhow::anyhow!("{e}"))?.map_err(|e| anyhow::anyhow!("{e}")),
r = smtp_handle => r.map_err(|e| anyhow::anyhow!("{e}"))?.map_err(|e| anyhow::anyhow!("{e}")),
}
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+73
View File
@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
+22
View File
@@ -0,0 +1,22 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
},
},
])
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ui</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+2759
View File
File diff suppressed because it is too large Load Diff
+32
View File
@@ -0,0 +1,32 @@
{
"name": "ui",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@tauri-apps/api": "^2.11.0",
"@tauri-apps/plugin-shell": "^2.3.5",
"react": "^19.2.6",
"react-dom": "^19.2.6"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/node": "^24.12.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.3.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"typescript": "~6.0.2",
"typescript-eslint": "^8.59.2",
"vite": "^8.0.12"
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+515
View File
@@ -0,0 +1,515 @@
.app {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
background: var(--surface);
}
/* ── Header ── */
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
background: var(--surface-container);
border-bottom: 1px solid var(--outline-variant);
flex-shrink: 0;
}
.header-left {
display: flex;
align-items: center;
gap: 14px;
}
.app-header h1 {
font-family: var(--brand);
font-size: 16px;
font-weight: 600;
margin: 0;
color: var(--on-surface);
letter-spacing: 0.2px;
}
.header-status {
display: flex;
align-items: center;
gap: 6px;
padding: 3px 10px 3px 8px;
background: var(--surface-container-high);
border-radius: 20px;
}
.header-status-text {
font-size: 11px;
font-weight: 500;
color: var(--on-surface-variant);
}
/* ── Tabs ── */
.tabs {
display: flex;
gap: 2px;
}
.tabs button {
background: none;
border: 1px solid transparent;
border-radius: 8px;
padding: 5px 12px;
font-size: 13px;
color: var(--on-surface-variant);
cursor: pointer;
transition: all 0.15s;
font-family: var(--sans);
}
.tabs button:hover {
background: var(--surface-container-high);
}
.tabs button.active {
background: var(--primary-container);
color: var(--on-primary-container);
border-color: transparent;
font-weight: 500;
}
/* ── Content ── */
.app-content {
flex: 1;
overflow-y: auto;
padding: 24px;
}
/* ── Shared ── */
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
display: inline-block;
}
.muted {
color: var(--on-surface-variant);
font-size: 13px;
}
.error-text {
font-size: 13px;
color: var(--error);
margin: 0;
padding: 10px 12px;
background: color-mix(in srgb, var(--error) 8%, transparent);
border-radius: 8px;
line-height: 1.4;
}
/* ── Dashboard ── */
.dashboard {
display: flex;
flex-direction: column;
gap: 16px;
}
.status-hero {
display: flex;
align-items: center;
gap: 14px;
padding: 18px 20px;
border-radius: 14px;
border: 1px solid var(--outline-variant);
background: var(--surface-container);
transition: all 0.3s;
}
.status-hero.running {
border-color: color-mix(in srgb, var(--green) 40%, transparent);
background: color-mix(in srgb, var(--success-container) 30%, var(--surface-container));
}
.status-hero.starting {
border-color: color-mix(in srgb, var(--orange) 40%, transparent);
}
.hero-indicator {
position: relative;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.pulse-ring {
position: absolute;
width: 36px;
height: 36px;
border-radius: 50%;
border: 2px solid var(--green);
opacity: 0;
animation: ping 2s cubic-bezier(0, 0, 0.2, 1) infinite;
}
.pulse-ring.orange {
border-color: var(--orange);
}
.pulse-dot {
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--green);
animation: breathe 2s ease-in-out infinite;
}
.pulse-dot.orange {
background: var(--orange);
}
.static-dot {
width: 12px;
height: 12px;
border-radius: 50%;
}
@keyframes ping {
0% { transform: scale(0.8); opacity: 0.6; }
75%, 100% { transform: scale(1.4); opacity: 0; }
}
@keyframes breathe {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.hero-text {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
.hero-text strong {
font-size: 15px;
color: var(--on-surface);
}
.hero-uptime {
font-size: 12px;
color: var(--on-surface-variant);
}
.hero-action {
padding: 6px 16px;
font-size: 12px;
flex-shrink: 0;
}
.start-section {
display: flex;
flex-direction: column;
gap: 12px;
}
.start-btn {
align-self: flex-start;
}
.stats-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.stat-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 20px 16px;
border-radius: 12px;
border: 1px solid var(--outline-variant);
background: var(--surface-container);
transition: border-color 0.2s;
}
.stat-card:hover {
border-color: color-mix(in srgb, var(--primary) 30%, var(--outline-variant));
}
.stat-value {
font-size: 28px;
font-weight: 600;
color: var(--on-surface);
font-family: var(--brand);
line-height: 1;
}
.stat-label {
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--on-surface-variant);
}
/* ── Connection Panel ── */
.connection-panel {
display: flex;
flex-direction: column;
gap: 16px;
}
.panel-subtitle {
font-size: 13px;
color: var(--on-surface-variant);
margin: 0;
}
.settings-card {
border: 1px solid var(--outline-variant);
border-radius: 14px;
background: var(--surface-container);
overflow: hidden;
}
.settings-section {
padding: 16px 20px;
}
.settings-section h3 {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--on-surface-variant);
margin: 0 0 10px;
}
.settings-divider {
height: 1px;
background: var(--outline-variant);
}
.settings-grid {
display: grid;
grid-template-columns: 72px 1fr;
gap: 6px 12px;
align-items: center;
}
.setting-key {
font-size: 12px;
color: var(--on-surface-variant);
}
.setting-val {
font-size: 14px;
font-family: var(--mono);
color: var(--on-surface);
background: none;
padding: 0;
}
.setting-val.copyable {
cursor: pointer;
border-radius: 4px;
transition: background 0.15s;
}
.setting-val.copyable:hover {
background: var(--surface-container-high);
}
.password-field-inline {
display: flex;
align-items: center;
gap: 8px;
}
.password-mono {
font-size: 13px;
letter-spacing: 0.3px;
}
.inline-btn {
padding: 2px 8px;
font-size: 11px;
font-weight: 500;
border: 1px solid var(--outline-variant);
border-radius: 6px;
background: var(--surface-container-high);
color: var(--on-surface-variant);
cursor: pointer;
font-family: var(--sans);
transition: all 0.15s;
flex-shrink: 0;
}
.inline-btn:hover {
background: var(--surface-container-highest);
color: var(--on-surface);
}
.regen-btn {
align-self: flex-start;
}
.settings-note {
font-size: 12px;
color: var(--on-surface-variant);
padding: 10px 14px;
background: color-mix(in srgb, var(--orange) 8%, transparent);
border-radius: 8px;
line-height: 1.5;
}
.settings-note strong {
color: var(--on-surface);
}
/* ── Config Panel ── */
.panel {
}
.panel h2 {
font-size: 14px;
font-weight: 600;
margin: 0 0 16px;
color: var(--on-surface);
}
/* ── Forms ── */
.form-group {
margin-bottom: 12px;
}
.form-group label {
display: block;
font-size: 11px;
font-weight: 600;
color: var(--on-surface-variant);
margin-bottom: 4px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.form-group input {
width: 100%;
padding: 9px 12px;
font-size: 14px;
border: 1px solid var(--outline-variant);
border-radius: 8px;
background: var(--surface);
color: var(--on-surface);
box-sizing: border-box;
font-family: var(--sans);
transition: border-color 0.15s, box-shadow 0.15s;
}
.form-group input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 15%, transparent);
}
.form-group input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.form-group input::placeholder {
color: var(--outline);
}
.form-row {
display: flex;
gap: 12px;
}
.form-row .form-group {
flex: 1;
}
/* ── Buttons ── */
button {
padding: 9px 18px;
font-size: 13px;
font-weight: 500;
border: 1px solid var(--outline-variant);
border-radius: 8px;
background: var(--surface-container-high);
color: var(--on-surface);
cursor: pointer;
font-family: var(--sans);
transition: all 0.15s;
}
button:hover:not(:disabled) {
background: var(--surface-container-highest);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
button.primary {
background: var(--primary);
color: var(--on-primary);
border-color: var(--primary);
}
button.primary:hover:not(:disabled) {
opacity: 0.92;
}
button.small {
padding: 4px 10px;
font-size: 12px;
}
/* ── Logs ── */
.logs-panel {
max-width: none;
display: flex;
flex-direction: column;
height: calc(100vh - 120px);
}
.logs-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.logs-header h2 {
margin: 0;
}
.logs-container {
flex: 1;
overflow-y: auto;
background: var(--surface-container);
border: 1px solid var(--outline-variant);
border-radius: 10px;
padding: 12px;
font-family: var(--mono);
font-size: 12px;
line-height: 1.7;
}
.logs-empty {
color: var(--outline);
font-style: italic;
}
.log-line {
color: var(--on-surface);
word-break: break-all;
}
+100
View File
@@ -0,0 +1,100 @@
import { useState } from "react";
import { useBridge } from "./hooks/useBridge";
import { Dashboard } from "./components/Dashboard";
import { ConnectionPanel } from "./components/ConnectionPanel";
import { ConfigPanel } from "./components/ConfigPanel";
import { LogsPanel } from "./components/LogsPanel";
import { statusLabel, isError } from "./types";
import "./App.css";
type Tab = "dashboard" | "connection" | "config" | "logs";
function App() {
const [tab, setTab] = useState<Tab>("dashboard");
const bridge = useBridge();
const status = bridge.status;
const isRunning = status === "Running";
const isStarting = status === "Starting";
const statusColor = isRunning
? "var(--green)"
: isStarting
? "var(--orange)"
: status && isError(status)
? "var(--red)"
: "var(--gray)";
return (
<div className="app">
<header className="app-header">
<div className="header-left">
<h1>TutaBridge</h1>
<div className="header-status">
<span className="status-dot" style={{ background: statusColor }} />
<span className="header-status-text">
{status ? statusLabel(status) : "Loading..."}
</span>
</div>
</div>
<nav className="tabs">
<button
className={tab === "dashboard" ? "active" : ""}
onClick={() => setTab("dashboard")}
>
Dashboard
</button>
<button
className={tab === "connection" ? "active" : ""}
onClick={() => setTab("connection")}
>
Connection
</button>
<button
className={tab === "config" ? "active" : ""}
onClick={() => setTab("config")}
>
Config
</button>
<button
className={tab === "logs" ? "active" : ""}
onClick={() => setTab("logs")}
>
Logs{bridge.logs.length > 0 ? ` (${bridge.logs.length})` : ""}
</button>
</nav>
</header>
<main className="app-content">
{tab === "dashboard" && (
<Dashboard
status={bridge.status}
stats={bridge.stats}
hasSavedSession={bridge.hasSavedSession}
loading={bridge.loading}
onStart={bridge.startBridge}
onStop={bridge.stopBridge}
/>
)}
{tab === "connection" && (
<ConnectionPanel
config={bridge.config}
status={bridge.status}
bridgePassword={bridge.bridgePassword}
onRegeneratePassword={bridge.regenerateBridgePassword}
/>
)}
{tab === "config" && (
<ConfigPanel
config={bridge.config}
status={bridge.status}
onSave={bridge.saveConfig}
/>
)}
{tab === "logs" && (
<LogsPanel logs={bridge.logs} onClear={bridge.clearLogs} />
)}
</main>
</div>
);
}
export default App;
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

+86
View File
@@ -0,0 +1,86 @@
import { useState, useEffect } from "react";
import type { Config, BridgeStatus } from "../types";
interface Props {
config: Config | null;
status: BridgeStatus | null;
onSave: (config: Config) => Promise<void>;
}
export function ConfigPanel({ config, status, onSave }: Props) {
const [email, setEmail] = useState("");
const [imapPort, setImapPort] = useState(1143);
const [smtpPort, setSmtpPort] = useState(1025);
const [apiUrl, setApiUrl] = useState("https://app.tuta.com");
const [saved, setSaved] = useState(false);
useEffect(() => {
if (config) {
setEmail(config.email);
setImapPort(config.imap_port);
setSmtpPort(config.smtp_port);
setApiUrl(config.api_url);
}
}, [config]);
const isRunning = status === "Running" || status === "Starting";
const handleSave = async () => {
await onSave({
email,
imap_port: imapPort,
smtp_port: smtpPort,
api_url: apiUrl,
});
setSaved(true);
setTimeout(() => setSaved(false), 2000);
};
return (
<div className="panel">
<h2>Configuration</h2>
<div className="form-group">
<label>Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={isRunning}
placeholder="your@tuta.com"
/>
</div>
<div className="form-row">
<div className="form-group">
<label>IMAP Port</label>
<input
type="number"
value={imapPort}
onChange={(e) => setImapPort(Number(e.target.value))}
disabled={isRunning}
/>
</div>
<div className="form-group">
<label>SMTP Port</label>
<input
type="number"
value={smtpPort}
onChange={(e) => setSmtpPort(Number(e.target.value))}
disabled={isRunning}
/>
</div>
</div>
<div className="form-group">
<label>API URL</label>
<input
type="url"
value={apiUrl}
onChange={(e) => setApiUrl(e.target.value)}
disabled={isRunning}
/>
</div>
<button onClick={handleSave} disabled={isRunning || !email}>
{saved ? "Saved!" : "Save"}
</button>
</div>
);
}
+111
View File
@@ -0,0 +1,111 @@
import { useState } from "react";
import type { Config, BridgeStatus } from "../types";
interface Props {
config: Config | null;
status: BridgeStatus | null;
bridgePassword: string | null;
onRegeneratePassword: () => Promise<string>;
}
export function ConnectionPanel({ config, status, bridgePassword, onRegeneratePassword }: Props) {
const [showPassword, setShowPassword] = useState(false);
const [copied, setCopied] = useState(false);
const isRunning = status === "Running" || status === "Starting";
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
if (!config) {
return <div className="connection-panel"><p className="muted">Loading...</p></div>;
}
return (
<div className="connection-panel">
<p className="panel-subtitle">Configure your mail client with these settings</p>
<div className="settings-card">
<div className="settings-section">
<h3>Incoming Mail (IMAP)</h3>
<div className="settings-grid">
<span className="setting-key">Server</span>
<code className="setting-val">127.0.0.1</code>
<span className="setting-key">Port</span>
<code className="setting-val">{config.imap_port}</code>
<span className="setting-key">Security</span>
<code className="setting-val">SSL/TLS</code>
</div>
</div>
<div className="settings-divider" />
<div className="settings-section">
<h3>Outgoing Mail (SMTP)</h3>
<div className="settings-grid">
<span className="setting-key">Server</span>
<code className="setting-val">127.0.0.1</code>
<span className="setting-key">Port</span>
<code className="setting-val">{config.smtp_port}</code>
<span className="setting-key">Security</span>
<code className="setting-val">SSL/TLS</code>
</div>
</div>
<div className="settings-divider" />
<div className="settings-section">
<h3>Authentication</h3>
<div className="settings-grid">
<span className="setting-key">Username</span>
<code className="setting-val copyable" onClick={() => copyToClipboard(config.email)}>
{config.email || "—"}
</code>
<span className="setting-key">Password</span>
<div className="password-field-inline">
{bridgePassword ? (
<>
<code className="setting-val password-mono">
{showPassword ? bridgePassword : "•".repeat(23)}
</code>
<button
className="inline-btn"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? "Hide" : "Show"}
</button>
<button
className="inline-btn"
onClick={() => copyToClipboard(bridgePassword)}
>
{copied ? "Copied!" : "Copy"}
</button>
</>
) : (
<span className="muted">Start the bridge to generate</span>
)}
</div>
</div>
</div>
</div>
{bridgePassword && (
<button
className="small regen-btn"
onClick={onRegeneratePassword}
disabled={isRunning}
title={isRunning ? "Stop the bridge first" : "Generate a new password"}
>
Regenerate password
</button>
)}
<div className="settings-note">
<strong>Note:</strong> Accept the self-signed certificate when your mail client prompts.
</div>
</div>
);
}
+128
View File
@@ -0,0 +1,128 @@
import { useState } from "react";
import type { BridgeStatus, BridgeStats } from "../types";
import { isError } from "../types";
interface Props {
status: BridgeStatus | null;
stats: BridgeStats;
hasSavedSession: boolean;
loading: boolean;
onStart: (password?: string) => Promise<void>;
onStop: () => Promise<void>;
}
function formatUptime(secs: number): string {
if (secs < 60) return `${secs}s`;
if (secs < 3600) return `${Math.floor(secs / 60)}m ${secs % 60}s`;
const h = Math.floor(secs / 3600);
const m = Math.floor((secs % 3600) / 60);
return `${h}h ${m}m`;
}
export function Dashboard({ status, stats, hasSavedSession, loading, onStart, onStop }: Props) {
const [password, setPassword] = useState("");
if (!status) {
return <div className="dashboard"><p className="muted">Connecting...</p></div>;
}
const isRunning = status === "Running";
const isStarting = status === "Starting";
const isStopped = status === "Stopped" || isError(status);
const needsPassword = isStopped && !hasSavedSession;
const handleStart = async () => {
if (needsPassword) {
await onStart(password);
setPassword("");
} else {
await onStart();
}
};
return (
<div className="dashboard">
{isRunning && (
<div className="status-hero running">
<div className="hero-indicator">
<span className="pulse-ring" />
<span className="pulse-dot" />
</div>
<div className="hero-text">
<strong>Bridge is running</strong>
{stats.uptime_secs != null && (
<span className="hero-uptime">Up {formatUptime(stats.uptime_secs)}</span>
)}
</div>
<button className="hero-action" onClick={onStop} disabled={loading}>
{loading ? "Stopping..." : "Stop"}
</button>
</div>
)}
{isStarting && (
<div className="status-hero starting">
<div className="hero-indicator">
<span className="pulse-ring orange" />
<span className="pulse-dot orange" />
</div>
<div className="hero-text">
<strong>Connecting...</strong>
</div>
</div>
)}
{isStopped && (
<div className="status-hero stopped">
<div className="hero-indicator">
<span className="static-dot" style={{ background: isError(status) ? "var(--red)" : "var(--gray)" }} />
</div>
<div className="hero-text">
<strong>{isError(status) ? "Connection failed" : "Bridge is stopped"}</strong>
</div>
</div>
)}
{isError(status) && (
<p className="error-text">{status.Error}</p>
)}
{isStopped && (
<div className="start-section">
{needsPassword && (
<div className="form-group">
<label>Tuta Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter your Tuta password"
onKeyDown={(e) => e.key === "Enter" && password && handleStart()}
/>
</div>
)}
<button
className="primary start-btn"
onClick={handleStart}
disabled={loading || (needsPassword && !password)}
>
{loading ? "Connecting..." : "Start Bridge"}
</button>
</div>
)}
<div className="stats-grid">
<div className="stat-card">
<span className="stat-value">{stats.mails_synced}</span>
<span className="stat-label">Emails synced</span>
</div>
<div className="stat-card">
<span className="stat-value">
{stats.uptime_secs != null ? formatUptime(stats.uptime_secs) : "--"}
</span>
<span className="stat-label">Uptime</span>
</div>
</div>
</div>
);
}
+37
View File
@@ -0,0 +1,37 @@
import { useEffect, useRef } from "react";
interface Props {
logs: string[];
onClear: () => void;
}
export function LogsPanel({ logs, onClear }: Props) {
const endRef = useRef<HTMLDivElement>(null);
useEffect(() => {
endRef.current?.scrollIntoView({ behavior: "smooth" });
}, [logs]);
return (
<div className="panel logs-panel">
<div className="logs-header">
<h2>Logs</h2>
<button className="small" onClick={onClear}>
Clear
</button>
</div>
<div className="logs-container">
{logs.length === 0 ? (
<span className="logs-empty">No logs yet</span>
) : (
logs.map((line, i) => (
<div key={i} className="log-line">
{line}
</div>
))
)}
<div ref={endRef} />
</div>
</div>
);
}
Binary file not shown.
+100
View File
@@ -0,0 +1,100 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import type { Config, BridgeStatus, BridgeStats } from "../types";
const MAX_LOG_LINES = 500;
const POLL_INTERVAL = 1000;
export function useBridge() {
const [config, setConfig] = useState<Config | null>(null);
const [status, setStatus] = useState<BridgeStatus | null>(null);
const [stats, setStats] = useState<BridgeStats>({ uptime_secs: null, mails_synced: 0 });
const [hasSavedSession, setHasSavedSession] = useState(false);
const [bridgePassword, setBridgePassword] = useState<string | null>(null);
const [logs, setLogs] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const refresh = useCallback(() => {
invoke<BridgeStatus>("get_status").then(setStatus);
invoke<BridgeStats>("get_stats").then(setStats);
}, []);
useEffect(() => {
invoke<Config>("get_config").then(setConfig);
invoke<boolean>("has_saved_session").then(setHasSavedSession);
invoke<string | null>("get_bridge_password").then(setBridgePassword);
refresh();
pollRef.current = setInterval(refresh, POLL_INTERVAL);
return () => {
if (pollRef.current) clearInterval(pollRef.current);
};
}, [refresh]);
useEffect(() => {
const unlisten = listen<string>("bridge://log", (event) => {
setLogs((prev) => {
const next = [...prev, event.payload];
return next.length > MAX_LOG_LINES ? next.slice(-MAX_LOG_LINES) : next;
});
});
return () => {
unlisten.then((fn) => fn());
};
}, []);
const saveConfig = useCallback(async (cfg: Config) => {
await invoke("save_config", { config: cfg });
setConfig(cfg);
}, []);
const startBridge = useCallback(async (password?: string) => {
setLoading(true);
try {
await invoke("start_bridge", { password: password || null });
refresh();
invoke<string | null>("get_bridge_password").then(setBridgePassword);
} catch (e) {
setStatus({ Error: String(e) });
} finally {
setLoading(false);
}
}, [refresh]);
const stopBridge = useCallback(async () => {
setLoading(true);
try {
await invoke("stop_bridge");
refresh();
} catch (e) {
setStatus({ Error: String(e) });
} finally {
setLoading(false);
}
}, [refresh]);
const clearLogs = useCallback(() => setLogs([]), []);
const regenerateBridgePassword = useCallback(async () => {
const newPassword = await invoke<string>("regenerate_bridge_password");
setBridgePassword(newPassword);
return newPassword;
}, []);
return {
config,
status,
stats,
hasSavedSession,
bridgePassword,
logs,
loading,
saveConfig,
startBridge,
stopBridge,
clearLogs,
regenerateBridgePassword,
};
}
+90
View File
@@ -0,0 +1,90 @@
@font-face {
font-family: 'MDIO';
src: url('./fonts/MDIO-Semibold.woff2') format('woff2');
font-weight: 600;
font-style: normal;
font-display: swap;
}
:root {
/* Tuta light red theme */
--primary: #8F4A4E;
--on-primary: #FFFFFF;
--primary-container: #F4D2D2;
--on-primary-container: #733337;
--secondary: #87521B;
--tertiary: #63568F;
--surface: #FFFFFF;
--surface-container: #FCF9F6;
--surface-container-high: #F5EEEA;
--surface-container-highest: #e7e2de;
--on-surface: #221A14;
--on-surface-variant: #4e4545;
--outline: #7f7575;
--outline-variant: #d0c4c4;
--scrim: #000000;
--error: #BA1A1A;
--on-error: #FFFFFF;
--success: #44845E;
--success-container: #E9FFED;
--warning: #8D7426;
--tuta-nota: #D93951;
--green: #44845E;
--orange: #8D7426;
--red: #BA1A1A;
--gray: #7f7575;
--sans: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Helvetica, Arial, sans-serif;
--mono: ui-monospace, "SF Mono", Consolas, monospace;
--brand: 'MDIO', var(--sans);
font: 16px/1.4286 var(--sans);
color-scheme: light dark;
color: var(--on-surface-variant);
background: var(--surface);
-webkit-font-smoothing: antialiased;
}
@media (prefers-color-scheme: dark) {
:root {
--primary: #FFB3B5;
--on-primary: #561D22;
--primary-container: #733337;
--on-primary-container: #FFDADA;
--secondary: #F3BD6E;
--tertiary: #CDBDFF;
--surface: #181212;
--surface-container: #241e1e;
--surface-container-high: #2f2828;
--surface-container-highest: #3d3434;
--on-surface: #f3ecec;
--on-surface-variant: #d0c4c4;
--outline: #998e8e;
--outline-variant: #4e4545;
--scrim: #000000;
--error: #FFB4AB;
--on-error: #690005;
--success: #5E9E77;
--success-container: #003920;
--warning: #FFE089;
--tuta-nota: #D93951;
--green: #5E9E77;
--orange: #FFE089;
--red: #FFB4AB;
--gray: #998e8e;
}
}
* {
box-sizing: border-box;
}
body {
margin: 0;
}
#root {
height: 100vh;
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
+22
View File
@@ -0,0 +1,22 @@
export interface Config {
email: string;
imap_port: number;
smtp_port: number;
api_url: string;
}
export type BridgeStatus = "Stopped" | "Starting" | "Running" | { Error: string };
export interface BridgeStats {
uptime_secs: number | null;
mails_synced: number;
}
export function statusLabel(status: BridgeStatus): string {
if (typeof status === "string") return status;
return `Error: ${status.Error}`;
}
export function isError(status: BridgeStatus): status is { Error: string } {
return typeof status !== "string";
}
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"module": "esnext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
clearScreen: false,
server: {
port: 1420,
strictPort: true,
},
});