mirror of
https://github.com/spartanz51/tutabridge.git
synced 2026-06-24 10:54:32 +02:00
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:
@@ -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"
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct Config {
|
||||
pub email: String,
|
||||
pub imap_port: u16,
|
||||
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 {
|
||||
"https://app.tuta.com".to_string()
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
email: String::new(),
|
||||
imap_port: 1143,
|
||||
smtp_port: 1025,
|
||||
api_url: default_api_url(),
|
||||
bridge_password: None,
|
||||
sync_limit: default_sync_limit(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn data_dir() -> PathBuf {
|
||||
dirs::config_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join("tutabridge")
|
||||
}
|
||||
|
||||
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();
|
||||
if path.exists() {
|
||||
let content = std::fs::read_to_string(&path)?;
|
||||
Ok(Some(toml::from_str(&content)?))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
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)]
|
||||
fn parse_config(content: &str) -> Result<Config, toml::de::Error> {
|
||||
toml::from_str(content)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_default_config() {
|
||||
let cfg = Config::default();
|
||||
assert_eq!(cfg.email, "");
|
||||
assert_eq!(cfg.imap_port, 1143);
|
||||
assert_eq!(cfg.smtp_port, 1025);
|
||||
assert_eq!(cfg.api_url, "https://app.tuta.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_full_config() {
|
||||
let toml = r#"
|
||||
email = "test@tuta.com"
|
||||
imap_port = 1993
|
||||
smtp_port = 1587
|
||||
api_url = "https://custom.tuta.com"
|
||||
"#;
|
||||
let cfg = parse_config(toml).unwrap();
|
||||
assert_eq!(cfg.email, "test@tuta.com");
|
||||
assert_eq!(cfg.imap_port, 1993);
|
||||
assert_eq!(cfg.smtp_port, 1587);
|
||||
assert_eq!(cfg.api_url, "https://custom.tuta.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_minimal_config() {
|
||||
let toml = r#"
|
||||
email = "test@tuta.com"
|
||||
imap_port = 1143
|
||||
smtp_port = 1025
|
||||
"#;
|
||||
let cfg = parse_config(toml).unwrap();
|
||||
assert_eq!(cfg.email, "test@tuta.com");
|
||||
assert_eq!(cfg.api_url, "https://app.tuta.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_config_missing_email() {
|
||||
let toml = r#"
|
||||
imap_port = 1143
|
||||
smtp_port = 1025
|
||||
"#;
|
||||
let result = parse_config(toml);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_config_invalid_port_type() {
|
||||
let toml = r#"
|
||||
email = "test@tuta.com"
|
||||
imap_port = "not_a_number"
|
||||
smtp_port = 1025
|
||||
"#;
|
||||
let result = parse_config(toml);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_roundtrip() {
|
||||
let cfg = Config {
|
||||
email: "roundtrip@tuta.com".to_string(),
|
||||
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();
|
||||
assert_eq!(cfg, deserialized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_config_extra_fields_ignored() {
|
||||
let toml = r#"
|
||||
email = "test@tuta.com"
|
||||
imap_port = 1143
|
||||
smtp_port = 1025
|
||||
unknown_field = "ignored"
|
||||
"#;
|
||||
// toml by default errors on unknown fields with deny_unknown_fields,
|
||||
// but serde default is to ignore them
|
||||
let result = parse_config(toml);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
mod session;
|
||||
|
||||
use std::sync::Arc;
|
||||
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,
|
||||
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 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, store, backend, pw_hash).await {
|
||||
error!("IMAP connection error: {}", e);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("IMAP TLS handshake failed: {}", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_connection(
|
||||
stream: tokio_rustls::server::TlsStream<tokio::net::TcpStream>,
|
||||
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 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() {
|
||||
line.clear();
|
||||
tokio::select! {
|
||||
result = reader.read_line(&mut line) => {
|
||||
let n = result?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
let trimmed = line.trim_end();
|
||||
debug!("IMAP C (idle): {}", trimmed);
|
||||
if trimmed.eq_ignore_ascii_case("DONE") {
|
||||
let responses = session.end_idle();
|
||||
for resp in &responses {
|
||||
debug!("IMAP S: {}", resp.trim_end());
|
||||
writer.write_all(resp.as_bytes()).await?;
|
||||
}
|
||||
writer.flush().await?;
|
||||
}
|
||||
}
|
||||
_ = store_watch.changed() => {
|
||||
let updates = session.check_new_mail().await;
|
||||
for resp in &updates {
|
||||
debug!("IMAP S (store update): {}", resp.trim_end());
|
||||
writer.write_all(resp.as_bytes()).await?;
|
||||
}
|
||||
writer.flush().await?;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
line.clear();
|
||||
let n = reader.read_line(&mut line).await?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
let trimmed = line.trim_end();
|
||||
debug!("IMAP C: {}", trimmed);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
@@ -0,0 +1,5 @@
|
||||
pub(crate) mod rfc2822;
|
||||
pub(crate) mod parser;
|
||||
|
||||
pub use rfc2822::mail_to_rfc2822;
|
||||
pub use parser::ParsedMessage;
|
||||
@@ -0,0 +1,536 @@
|
||||
use base64::Engine;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)]
|
||||
pub struct ParsedMessage {
|
||||
pub from_address: String,
|
||||
pub from_name: String,
|
||||
pub to: Vec<(String, String)>,
|
||||
pub cc: Vec<(String, String)>,
|
||||
pub bcc: Vec<(String, String)>,
|
||||
pub subject: String,
|
||||
pub body_html: String,
|
||||
}
|
||||
|
||||
pub fn parse_rfc2822(raw: &str) -> ParsedMessage {
|
||||
let (header_section, body_section) = split_headers_body(raw);
|
||||
let headers = parse_headers(&header_section);
|
||||
|
||||
let from_raw = get_header(&headers, "from").unwrap_or_default();
|
||||
let (from_name, from_address) = parse_address_single(&from_raw);
|
||||
|
||||
let to = get_header(&headers, "to")
|
||||
.map(|v| parse_address_list(&v))
|
||||
.unwrap_or_default();
|
||||
let cc = get_header(&headers, "cc")
|
||||
.map(|v| parse_address_list(&v))
|
||||
.unwrap_or_default();
|
||||
let bcc = get_header(&headers, "bcc")
|
||||
.map(|v| parse_address_list(&v))
|
||||
.unwrap_or_default();
|
||||
|
||||
let subject = get_header(&headers, "subject")
|
||||
.map(|s| decode_header_value(&s))
|
||||
.unwrap_or_default();
|
||||
|
||||
let content_type = get_header(&headers, "content-type").unwrap_or_default();
|
||||
let content_transfer_encoding = get_header(&headers, "content-transfer-encoding")
|
||||
.unwrap_or_default()
|
||||
.to_lowercase();
|
||||
|
||||
let body_html = if content_type.to_lowercase().contains("multipart/") {
|
||||
extract_multipart_body(&body_section, &content_type)
|
||||
} else {
|
||||
decode_body(&body_section, &content_transfer_encoding, &content_type.to_lowercase())
|
||||
};
|
||||
|
||||
ParsedMessage {
|
||||
from_address,
|
||||
from_name,
|
||||
to,
|
||||
cc,
|
||||
bcc,
|
||||
subject,
|
||||
body_html,
|
||||
}
|
||||
}
|
||||
|
||||
fn split_headers_body(raw: &str) -> (String, String) {
|
||||
if let Some(pos) = raw.find("\r\n\r\n") {
|
||||
(raw[..pos].to_string(), raw[pos + 4..].to_string())
|
||||
} else if let Some(pos) = raw.find("\n\n") {
|
||||
(raw[..pos].to_string(), raw[pos + 2..].to_string())
|
||||
} else {
|
||||
(raw.to_string(), String::new())
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_headers(header_section: &str) -> Vec<(String, String)> {
|
||||
let mut headers = Vec::new();
|
||||
let mut current_name = String::new();
|
||||
let mut current_value = String::new();
|
||||
|
||||
for line in header_section.lines() {
|
||||
if line.starts_with(' ') || line.starts_with('\t') {
|
||||
current_value.push(' ');
|
||||
current_value.push_str(line.trim());
|
||||
} else if let Some((name, value)) = line.split_once(':') {
|
||||
if !current_name.is_empty() {
|
||||
headers.push((current_name.to_lowercase(), current_value.trim().to_string()));
|
||||
}
|
||||
current_name = name.trim().to_string();
|
||||
current_value = value.to_string();
|
||||
}
|
||||
}
|
||||
if !current_name.is_empty() {
|
||||
headers.push((current_name.to_lowercase(), current_value.trim().to_string()));
|
||||
}
|
||||
headers
|
||||
}
|
||||
|
||||
fn get_header(headers: &[(String, String)], name: &str) -> Option<String> {
|
||||
headers.iter().find(|(n, _)| n == name).map(|(_, v)| v.clone())
|
||||
}
|
||||
|
||||
fn parse_address_single(raw: &str) -> (String, String) {
|
||||
let raw = raw.trim();
|
||||
if let Some(lt) = raw.find('<') {
|
||||
if let Some(gt) = raw.find('>') {
|
||||
let addr = raw[lt + 1..gt].trim().to_string();
|
||||
let name = decode_header_value(raw[..lt].trim().trim_matches('"'));
|
||||
return (name, addr);
|
||||
}
|
||||
}
|
||||
(String::new(), raw.to_string())
|
||||
}
|
||||
|
||||
fn parse_address_list(raw: &str) -> Vec<(String, String)> {
|
||||
let mut result = Vec::new();
|
||||
let mut depth = 0i32;
|
||||
let mut current = String::new();
|
||||
|
||||
for ch in raw.chars() {
|
||||
match ch {
|
||||
'<' => {
|
||||
depth += 1;
|
||||
current.push(ch);
|
||||
}
|
||||
'>' => {
|
||||
depth -= 1;
|
||||
current.push(ch);
|
||||
}
|
||||
',' if depth == 0 => {
|
||||
let trimmed = current.trim().to_string();
|
||||
if !trimmed.is_empty() {
|
||||
result.push(parse_address_single(&trimmed));
|
||||
}
|
||||
current.clear();
|
||||
}
|
||||
_ => current.push(ch),
|
||||
}
|
||||
}
|
||||
let trimmed = current.trim().to_string();
|
||||
if !trimmed.is_empty() {
|
||||
result.push(parse_address_single(&trimmed));
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn decode_header_value(s: &str) -> String {
|
||||
let s = s.trim();
|
||||
if !s.contains("=?") {
|
||||
return s.to_string();
|
||||
}
|
||||
|
||||
let mut result = String::new();
|
||||
let mut remaining = s;
|
||||
|
||||
while let Some(start) = remaining.find("=?") {
|
||||
result.push_str(&remaining[..start]);
|
||||
remaining = &remaining[start + 2..];
|
||||
|
||||
let parts: Vec<&str> = remaining.splitn(4, '?').collect();
|
||||
if parts.len() >= 3 {
|
||||
let encoding = parts[1].to_uppercase();
|
||||
let encoded = parts[2];
|
||||
if let Some(end_marker) = remaining.find("?=") {
|
||||
let decoded = if encoding == "B" {
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(encoded)
|
||||
.ok()
|
||||
.and_then(|bytes| String::from_utf8(bytes).ok())
|
||||
} else if encoding == "Q" {
|
||||
Some(decode_q_encoding(encoded))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(text) = decoded {
|
||||
result.push_str(&text);
|
||||
remaining = &remaining[end_marker + 2..];
|
||||
let ws_stripped = remaining.trim_start();
|
||||
if ws_stripped.starts_with("=?") {
|
||||
remaining = ws_stripped;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
result.push_str("=?");
|
||||
}
|
||||
result.push_str(remaining);
|
||||
result
|
||||
}
|
||||
|
||||
fn decode_q_encoding(s: &str) -> String {
|
||||
let mut result = Vec::new();
|
||||
let bytes = s.as_bytes();
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] == b'=' && i + 2 < bytes.len() {
|
||||
if let Ok(byte) = u8::from_str_radix(
|
||||
std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or(""),
|
||||
16,
|
||||
) {
|
||||
result.push(byte);
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if bytes[i] == b'_' {
|
||||
result.push(b' ');
|
||||
} else {
|
||||
result.push(bytes[i]);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
String::from_utf8(result).unwrap_or_else(|_| s.to_string())
|
||||
}
|
||||
|
||||
fn extract_boundary(content_type: &str) -> Option<String> {
|
||||
let lower = content_type.to_lowercase();
|
||||
if let Some(pos) = lower.find("boundary=") {
|
||||
let rest = &content_type[pos + 9..];
|
||||
let boundary = if rest.starts_with('"') {
|
||||
rest[1..].split('"').next().unwrap_or("")
|
||||
} else {
|
||||
rest.split(|c: char| c.is_whitespace() || c == ';').next().unwrap_or("")
|
||||
};
|
||||
if !boundary.is_empty() {
|
||||
return Some(boundary.to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn extract_multipart_body(body: &str, content_type: &str) -> String {
|
||||
let boundary = match extract_boundary(content_type) {
|
||||
Some(b) => b,
|
||||
None => return body.to_string(),
|
||||
};
|
||||
|
||||
let parts = split_mime_parts(body, &boundary);
|
||||
let mut html_part = None;
|
||||
let mut text_part = None;
|
||||
|
||||
for part in &parts {
|
||||
let (part_headers_str, part_body) = split_headers_body(part);
|
||||
let part_headers = parse_headers(&part_headers_str);
|
||||
let part_ct = get_header(&part_headers, "content-type").unwrap_or_default();
|
||||
let part_cte = get_header(&part_headers, "content-transfer-encoding")
|
||||
.unwrap_or_default()
|
||||
.to_lowercase();
|
||||
let part_ct_lower = part_ct.to_lowercase();
|
||||
|
||||
if part_ct_lower.contains("multipart/") {
|
||||
let nested = extract_multipart_body(&part_body, &part_ct);
|
||||
if !nested.is_empty() {
|
||||
return nested;
|
||||
}
|
||||
} else if part_ct_lower.contains("text/html") {
|
||||
html_part = Some(decode_body(&part_body, &part_cte, &part_ct_lower));
|
||||
} else if part_ct_lower.contains("text/plain") && html_part.is_none() {
|
||||
text_part = Some(decode_body(&part_body, &part_cte, &part_ct_lower));
|
||||
}
|
||||
}
|
||||
|
||||
html_part
|
||||
.or(text_part)
|
||||
.unwrap_or_else(|| body.to_string())
|
||||
}
|
||||
|
||||
fn split_mime_parts(body: &str, boundary: &str) -> Vec<String> {
|
||||
let delimiter = format!("--{}", boundary);
|
||||
let end_delimiter = format!("--{}--", boundary);
|
||||
let mut parts = Vec::new();
|
||||
let mut in_part = false;
|
||||
let mut current = String::new();
|
||||
|
||||
for line in body.lines() {
|
||||
if line.starts_with(&end_delimiter) {
|
||||
if in_part && !current.is_empty() {
|
||||
parts.push(current.trim_start_matches("\r\n").trim_start_matches('\n').to_string());
|
||||
}
|
||||
break;
|
||||
}
|
||||
if line.starts_with(&delimiter) {
|
||||
if in_part && !current.is_empty() {
|
||||
parts.push(current.trim_start_matches("\r\n").trim_start_matches('\n').to_string());
|
||||
}
|
||||
current = String::new();
|
||||
in_part = true;
|
||||
continue;
|
||||
}
|
||||
if in_part {
|
||||
current.push_str(line);
|
||||
current.push('\n');
|
||||
}
|
||||
}
|
||||
parts
|
||||
}
|
||||
|
||||
fn decode_body(body: &str, transfer_encoding: &str, content_type: &str) -> String {
|
||||
let decoded = if transfer_encoding.contains("base64") {
|
||||
let clean: String = body.chars().filter(|c| !c.is_whitespace()).collect();
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(&clean)
|
||||
.ok()
|
||||
.and_then(|bytes| String::from_utf8(bytes).ok())
|
||||
.unwrap_or_else(|| body.to_string())
|
||||
} else if transfer_encoding.contains("quoted-printable") {
|
||||
decode_quoted_printable(body)
|
||||
} else {
|
||||
body.to_string()
|
||||
};
|
||||
|
||||
if content_type.contains("text/plain") && !content_type.contains("text/html") {
|
||||
format!("<pre>{}</pre>", html_escape(&decoded))
|
||||
} else {
|
||||
decoded
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_quoted_printable(s: &str) -> String {
|
||||
let mut result = Vec::new();
|
||||
let bytes = s.as_bytes();
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] == b'=' {
|
||||
if i + 2 < bytes.len() && bytes[i + 1] == b'\r' && bytes[i + 2] == b'\n' {
|
||||
i += 3;
|
||||
} else if i + 1 < bytes.len() && bytes[i + 1] == b'\n' {
|
||||
i += 2;
|
||||
} else if i + 2 < bytes.len() {
|
||||
let hex = [bytes[i + 1], bytes[i + 2]];
|
||||
if let Ok(val) = u8::from_str_radix(
|
||||
std::str::from_utf8(&hex).unwrap_or(""),
|
||||
16,
|
||||
) {
|
||||
result.push(val);
|
||||
}
|
||||
i += 3;
|
||||
} else {
|
||||
result.push(b'=');
|
||||
i += 1;
|
||||
}
|
||||
} else {
|
||||
result.push(bytes[i]);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
String::from_utf8(result).unwrap_or_else(|_| s.to_string())
|
||||
}
|
||||
|
||||
fn html_escape(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_simple_message() {
|
||||
let raw = "From: Alice <alice@example.com>\r\nTo: Bob <bob@example.com>\r\nSubject: Hello\r\n\r\n<p>Hi Bob</p>";
|
||||
let msg = parse_rfc2822(raw);
|
||||
assert_eq!(msg.from_name, "Alice");
|
||||
assert_eq!(msg.from_address, "alice@example.com");
|
||||
assert_eq!(msg.to.len(), 1);
|
||||
assert_eq!(msg.to[0].1, "bob@example.com");
|
||||
assert_eq!(msg.subject, "Hello");
|
||||
assert_eq!(msg.body_html, "<p>Hi Bob</p>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_multiple_recipients() {
|
||||
let raw = "From: a@b.com\r\nTo: Bob <bob@x.com>, Charlie <charlie@x.com>\r\nCc: Dave <dave@x.com>\r\nSubject: Test\r\n\r\nbody";
|
||||
let msg = parse_rfc2822(raw);
|
||||
assert_eq!(msg.to.len(), 2);
|
||||
assert_eq!(msg.to[0].1, "bob@x.com");
|
||||
assert_eq!(msg.to[1].1, "charlie@x.com");
|
||||
assert_eq!(msg.cc.len(), 1);
|
||||
assert_eq!(msg.cc[0].1, "dave@x.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_base64_body() {
|
||||
let body_b64 = base64::engine::general_purpose::STANDARD.encode(b"<p>Hello</p>");
|
||||
let raw = format!(
|
||||
"From: a@b.com\r\nTo: b@c.com\r\nSubject: Test\r\nContent-Transfer-Encoding: base64\r\nContent-Type: text/html\r\n\r\n{}",
|
||||
body_b64
|
||||
);
|
||||
let msg = parse_rfc2822(&raw);
|
||||
assert_eq!(msg.body_html, "<p>Hello</p>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_plain_text_body() {
|
||||
let raw = "From: a@b.com\r\nTo: b@c.com\r\nSubject: Test\r\nContent-Type: text/plain\r\n\r\nHello <world>";
|
||||
let msg = parse_rfc2822(raw);
|
||||
assert_eq!(msg.body_html, "<pre>Hello <world></pre>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_encoded_subject() {
|
||||
let raw = "From: a@b.com\r\nTo: b@c.com\r\nSubject: =?UTF-8?B?SMOpbGxv?=\r\n\r\nbody";
|
||||
let msg = parse_rfc2822(raw);
|
||||
assert_eq!(msg.subject, "H\u{e9}llo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_address_no_name() {
|
||||
let (name, addr) = parse_address_single("bob@example.com");
|
||||
assert_eq!(name, "");
|
||||
assert_eq!(addr, "bob@example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_address_with_quotes() {
|
||||
let (name, addr) = parse_address_single("\"John Doe\" <john@x.com>");
|
||||
assert_eq!(name, "John Doe");
|
||||
assert_eq!(addr, "john@x.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decode_q_encoding() {
|
||||
assert_eq!(decode_q_encoding("Hello_=C3=A9"), "Hello \u{e9}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_headers_body_lf() {
|
||||
let raw = "From: a@b.com\nTo: b@c.com\n\nBody";
|
||||
let (h, b) = split_headers_body(raw);
|
||||
assert_eq!(h, "From: a@b.com\nTo: b@c.com");
|
||||
assert_eq!(b, "Body");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_folded_headers() {
|
||||
let raw = "From: a@b.com\r\nSubject: very long\r\n subject line\r\nTo: b@c.com\r\n\r\nbody";
|
||||
let msg = parse_rfc2822(raw);
|
||||
assert_eq!(msg.subject, "very long subject line");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multipart_alternative() {
|
||||
let raw = "From: a@b.com\r\nTo: b@c.com\r\nSubject: Test\r\nContent-Type: multipart/alternative; boundary=\"abc123\"\r\n\r\n--abc123\r\nContent-Type: text/plain\r\n\r\nHello plain\r\n--abc123\r\nContent-Type: text/html\r\n\r\n<p>Hello HTML</p>\r\n--abc123--";
|
||||
let msg = parse_rfc2822(raw);
|
||||
assert!(msg.body_html.contains("Hello HTML"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multipart_mixed_with_nested() {
|
||||
let raw = "From: a@b.com\r\nTo: b@c.com\r\nSubject: Test\r\nContent-Type: multipart/mixed; boundary=\"outer\"\r\n\r\n--outer\r\nContent-Type: multipart/alternative; boundary=\"inner\"\r\n\r\n--inner\r\nContent-Type: text/plain\r\n\r\nPlain text\r\n--inner\r\nContent-Type: text/html\r\n\r\n<p>HTML body</p>\r\n--inner--\r\n--outer--";
|
||||
let msg = parse_rfc2822(raw);
|
||||
assert!(msg.body_html.contains("HTML body"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multipart_plain_only() {
|
||||
let raw = "From: a@b.com\r\nTo: b@c.com\r\nSubject: Test\r\nContent-Type: multipart/alternative; boundary=\"bnd\"\r\n\r\n--bnd\r\nContent-Type: text/plain\r\n\r\nJust plain\r\n--bnd--";
|
||||
let msg = parse_rfc2822(raw);
|
||||
assert!(msg.body_html.contains("Just plain"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_boundary_quoted() {
|
||||
assert_eq!(
|
||||
extract_boundary("multipart/alternative; boundary=\"abc_123\""),
|
||||
Some("abc_123".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_boundary_unquoted() {
|
||||
assert_eq!(
|
||||
extract_boundary("multipart/mixed; boundary=abc123"),
|
||||
Some("abc123".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multipart_base64_part() {
|
||||
let body_b64 = base64::engine::general_purpose::STANDARD.encode(b"<p>Encoded</p>");
|
||||
let raw = format!(
|
||||
"From: a@b.com\r\nTo: b@c.com\r\nSubject: Test\r\nContent-Type: multipart/alternative; boundary=\"sep\"\r\n\r\n--sep\r\nContent-Type: text/html\r\nContent-Transfer-Encoding: base64\r\n\r\n{}\r\n--sep--",
|
||||
body_b64
|
||||
);
|
||||
let msg = parse_rfc2822(&raw);
|
||||
assert_eq!(msg.body_html, "<p>Encoded</p>");
|
||||
}
|
||||
|
||||
// --- multi-encoded-word subjects ---
|
||||
|
||||
#[test]
|
||||
fn test_decode_multi_encoded_words() {
|
||||
let s = "=?UTF-8?B?SMOpbGxv?= =?UTF-8?B?IE1vbmRl?=";
|
||||
let result = decode_header_value(s);
|
||||
assert_eq!(result, "H\u{e9}llo Monde");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decode_mixed_encoded_and_plain() {
|
||||
let s = "Re: =?UTF-8?B?SMOpbGxv?= there";
|
||||
let result = decode_header_value(s);
|
||||
assert_eq!(result, "Re: H\u{e9}llo there");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decode_q_encoded_word() {
|
||||
let s = "=?UTF-8?Q?Hello_=C3=A9?=";
|
||||
let result = decode_header_value(s);
|
||||
assert_eq!(result, "Hello \u{e9}");
|
||||
}
|
||||
|
||||
// --- quoted-printable soft break ---
|
||||
|
||||
#[test]
|
||||
fn test_qp_soft_break_crlf() {
|
||||
let input = "Hello=\r\nWorld";
|
||||
let result = decode_quoted_printable(input);
|
||||
assert_eq!(result, "HelloWorld");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_qp_soft_break_lf() {
|
||||
let input = "Hello=\nWorld";
|
||||
let result = decode_quoted_printable(input);
|
||||
assert_eq!(result, "HelloWorld");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_qp_no_byte_loss() {
|
||||
let input = "line1=\nABC";
|
||||
let result = decode_quoted_printable(input);
|
||||
assert_eq!(result, "line1ABC");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_qp_encoded_chars() {
|
||||
let input = "caf=C3=A9";
|
||||
let result = decode_quoted_printable(input);
|
||||
assert_eq!(result, "caf\u{e9}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,497 @@
|
||||
use base64::Engine;
|
||||
use tutasdk::entities::generated::tutanota::{Mail, MailAddress, MailDetails};
|
||||
|
||||
pub fn mail_to_rfc2822(mail: &Mail, details: Option<&MailDetails>) -> String {
|
||||
let mut msg = String::with_capacity(4096);
|
||||
|
||||
let date_str = format_rfc2822_date(mail.receivedDate.as_millis());
|
||||
msg.push_str(&format!("Date: {}\r\n", date_str));
|
||||
|
||||
msg.push_str(&format!("From: {}\r\n", format_address(&mail.sender)));
|
||||
|
||||
msg.push_str(&format!(
|
||||
"Subject: {}\r\n",
|
||||
encode_header_value(&mail.subject)
|
||||
));
|
||||
|
||||
if let Some(details) = details {
|
||||
let to_addrs: Vec<String> = details
|
||||
.recipients
|
||||
.toRecipients
|
||||
.iter()
|
||||
.map(format_address)
|
||||
.collect();
|
||||
if !to_addrs.is_empty() {
|
||||
msg.push_str(&format!("To: {}\r\n", to_addrs.join(", ")));
|
||||
}
|
||||
|
||||
let cc_addrs: Vec<String> = details
|
||||
.recipients
|
||||
.ccRecipients
|
||||
.iter()
|
||||
.map(format_address)
|
||||
.collect();
|
||||
if !cc_addrs.is_empty() {
|
||||
msg.push_str(&format!("Cc: {}\r\n", cc_addrs.join(", ")));
|
||||
}
|
||||
} else if let Some(ref first) = mail.firstRecipient {
|
||||
msg.push_str(&format!("To: {}\r\n", format_address(first)));
|
||||
}
|
||||
|
||||
msg.push_str("MIME-Version: 1.0\r\n");
|
||||
msg.push_str("Content-Type: text/html; charset=UTF-8\r\n");
|
||||
msg.push_str("Content-Transfer-Encoding: base64\r\n");
|
||||
|
||||
if let Some(ref id) = mail._id {
|
||||
msg.push_str(&format!(
|
||||
"Message-ID: <{}.{}@tutabridge.local>\r\n",
|
||||
id.list_id, id.element_id
|
||||
));
|
||||
}
|
||||
|
||||
msg.push_str("\r\n");
|
||||
|
||||
let body_text = details
|
||||
.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());
|
||||
msg.push_str(&encoded);
|
||||
msg.push_str("\r\n");
|
||||
|
||||
msg
|
||||
}
|
||||
|
||||
pub(crate) fn format_address(addr: &MailAddress) -> String {
|
||||
if addr.name.is_empty() {
|
||||
addr.address.clone()
|
||||
} else {
|
||||
format!("{} <{}>", encode_header_value(&addr.name), addr.address)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn encode_header_value(s: &str) -> String {
|
||||
if s.is_ascii() && !s.contains('\r') && !s.contains('\n') {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!(
|
||||
"=?UTF-8?B?{}?=",
|
||||
base64::engine::general_purpose::STANDARD.encode(s.as_bytes())
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn format_rfc2822_date(millis: u64) -> String {
|
||||
let secs = millis / 1000;
|
||||
let days = secs / 86400;
|
||||
let time_of_day = secs % 86400;
|
||||
let hours = time_of_day / 3600;
|
||||
let minutes = (time_of_day % 3600) / 60;
|
||||
let seconds = time_of_day % 60;
|
||||
|
||||
let weekday = ((days + 4) % 7) as usize; // 0=Sun, epoch was Thursday
|
||||
let weekdays = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
|
||||
let (year, month, day) = days_to_ymd(days);
|
||||
let months = [
|
||||
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
|
||||
];
|
||||
|
||||
let month_idx = month.saturating_sub(1).min(11) as usize;
|
||||
|
||||
format!(
|
||||
"{}, {:02} {} {:04} {:02}:{:02}:{:02} +0000",
|
||||
weekdays[weekday], day, months[month_idx], year, hours, minutes, seconds
|
||||
)
|
||||
}
|
||||
|
||||
/// Howard Hinnant's civil_from_days algorithm
|
||||
/// Returns (year, month 1-12, day 1-31)
|
||||
pub(crate) fn days_to_ymd(days: u64) -> (u64, u64, u64) {
|
||||
let z = days + 719468;
|
||||
let era = z / 146097;
|
||||
let doe = z - era * 146097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = if m <= 2 { y + 1 } else { y };
|
||||
(y, m, d)
|
||||
}
|
||||
|
||||
pub(crate) fn format_internal_date(millis: u64) -> String {
|
||||
let secs = millis / 1000;
|
||||
let days = secs / 86400;
|
||||
let tod = secs % 86400;
|
||||
let h = tod / 3600;
|
||||
let m = (tod % 3600) / 60;
|
||||
let s = tod % 60;
|
||||
|
||||
let (year, month, day) = days_to_ymd(days);
|
||||
let months = [
|
||||
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
|
||||
];
|
||||
|
||||
let month_idx = month.saturating_sub(1).min(11) as usize;
|
||||
|
||||
format!(
|
||||
"{:02}-{}-{:04} {:02}:{:02}:{:02} +0000",
|
||||
day, months[month_idx], year, h, m, s
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn base64_encode_body(data: &[u8]) -> String {
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(data);
|
||||
encoded
|
||||
.as_bytes()
|
||||
.chunks(76)
|
||||
.map(|chunk| std::str::from_utf8(chunk).unwrap_or(""))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\r\n")
|
||||
}
|
||||
|
||||
pub(crate) fn extract_headers(rfc: &str) -> String {
|
||||
if let Some(pos) = rfc.find("\r\n\r\n") {
|
||||
format!("{}\r\n", &rfc[..pos + 2])
|
||||
} else {
|
||||
rfc.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_id(s: &str) -> tutasdk::GeneratedId {
|
||||
tutasdk::GeneratedId(s.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_days_to_ymd_epoch() {
|
||||
assert_eq!(days_to_ymd(0), (1970, 1, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_days_to_ymd_known_dates() {
|
||||
// 2024-01-01 = day 19723 since epoch
|
||||
assert_eq!(days_to_ymd(19723), (2024, 1, 1));
|
||||
// 2000-02-29 (leap year) = day 11016
|
||||
assert_eq!(days_to_ymd(11016), (2000, 2, 29));
|
||||
// 2026-05-20 = day 20593
|
||||
assert_eq!(days_to_ymd(20593), (2026, 5, 20));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_rfc2822_date_epoch() {
|
||||
let result = format_rfc2822_date(0);
|
||||
assert_eq!(result, "Thu, 01 Jan 1970 00:00:00 +0000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_rfc2822_date_known() {
|
||||
// 2024-12-25 12:37:25 UTC = 1735130245000 ms
|
||||
let result = format_rfc2822_date(1735130245000);
|
||||
assert_eq!(result, "Wed, 25 Dec 2024 12:37:25 +0000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_internal_date_epoch() {
|
||||
let result = format_internal_date(0);
|
||||
assert_eq!(result, "01-Jan-1970 00:00:00 +0000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_internal_date_known() {
|
||||
let result = format_internal_date(1735130245000);
|
||||
assert_eq!(result, "25-Dec-2024 12:37:25 +0000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encode_header_ascii() {
|
||||
assert_eq!(encode_header_value("Hello World"), "Hello World");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encode_header_utf8() {
|
||||
let result = encode_header_value("Héllo Wörld");
|
||||
assert!(result.starts_with("=?UTF-8?B?"));
|
||||
assert!(result.ends_with("?="));
|
||||
|
||||
// Decode to verify round-trip
|
||||
let b64_part = &result[10..result.len() - 2];
|
||||
let decoded = base64::engine::general_purpose::STANDARD
|
||||
.decode(b64_part)
|
||||
.unwrap();
|
||||
assert_eq!(String::from_utf8(decoded).unwrap(), "Héllo Wörld");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encode_header_with_newline() {
|
||||
let result = encode_header_value("Line1\r\nLine2");
|
||||
assert!(result.starts_with("=?UTF-8?B?"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encode_header_empty() {
|
||||
assert_eq!(encode_header_value(""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_address_name_and_email() {
|
||||
let addr = MailAddress {
|
||||
_id: None,
|
||||
name: "John Doe".to_string(),
|
||||
address: "john@example.com".to_string(),
|
||||
contact: None,
|
||||
_errors: Default::default(),
|
||||
};
|
||||
assert_eq!(format_address(&addr), "John Doe <john@example.com>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_address_email_only() {
|
||||
let addr = MailAddress {
|
||||
_id: None,
|
||||
name: "".to_string(),
|
||||
address: "john@example.com".to_string(),
|
||||
contact: None,
|
||||
_errors: Default::default(),
|
||||
};
|
||||
assert_eq!(format_address(&addr), "john@example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_address_utf8_name() {
|
||||
let addr = MailAddress {
|
||||
_id: None,
|
||||
name: "Jéan-François".to_string(),
|
||||
address: "jf@example.com".to_string(),
|
||||
contact: None,
|
||||
_errors: Default::default(),
|
||||
};
|
||||
let result = format_address(&addr);
|
||||
assert!(result.contains("=?UTF-8?B?"));
|
||||
assert!(result.ends_with(" <jf@example.com>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_base64_encode_body_short() {
|
||||
let result = base64_encode_body(b"Hello");
|
||||
assert_eq!(result, "SGVsbG8=");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_base64_encode_body_long_wraps() {
|
||||
let long_text = "A".repeat(200);
|
||||
let result = base64_encode_body(long_text.as_bytes());
|
||||
for line in result.split("\r\n") {
|
||||
assert!(line.len() <= 76, "Line too long: {} chars", line.len());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_base64_encode_body_empty() {
|
||||
assert_eq!(base64_encode_body(b""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_headers_normal() {
|
||||
let rfc = "From: a@b.com\r\nTo: c@d.com\r\n\r\nBody here";
|
||||
let headers = extract_headers(rfc);
|
||||
// extract_headers includes the trailing \r\n\r\n separator
|
||||
assert_eq!(headers, "From: a@b.com\r\nTo: c@d.com\r\n\r\n");
|
||||
assert!(!headers.contains("Body"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_headers_no_body() {
|
||||
let rfc = "From: a@b.com\r\nTo: c@d.com";
|
||||
let headers = extract_headers(rfc);
|
||||
assert_eq!(headers, rfc);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mail_to_rfc2822_minimal() {
|
||||
use tutasdk::date::DateTime;
|
||||
use tutasdk::IdTupleGenerated;
|
||||
|
||||
let mail = Mail {
|
||||
_id: Some(IdTupleGenerated::new(
|
||||
test_id("list1"),
|
||||
test_id("elem1"),
|
||||
)),
|
||||
_permissions: test_id("perm1"),
|
||||
_format: 0,
|
||||
_ownerEncSessionKey: None,
|
||||
subject: "Test Subject".to_string(),
|
||||
receivedDate: DateTime::from_millis(1735130245000),
|
||||
state: 2,
|
||||
unread: false,
|
||||
confidential: false,
|
||||
replyType: 0,
|
||||
_ownerGroup: None,
|
||||
differentEnvelopeSender: None,
|
||||
listUnsubscribe: false,
|
||||
movedTime: None,
|
||||
phishingStatus: 0,
|
||||
authStatus: None,
|
||||
method: 0,
|
||||
recipientCount: 1,
|
||||
encryptionAuthStatus: None,
|
||||
_ownerKeyVersion: None,
|
||||
processingState: 0,
|
||||
processNeeded: false,
|
||||
sendAt: None,
|
||||
serverClassificationData: None,
|
||||
_kdfNonce: None,
|
||||
sender: MailAddress {
|
||||
_id: None,
|
||||
name: "Alice".to_string(),
|
||||
address: "alice@tuta.com".to_string(),
|
||||
contact: None,
|
||||
_errors: Default::default(),
|
||||
},
|
||||
attachments: vec![],
|
||||
conversationEntry: IdTupleGenerated::new(
|
||||
test_id("conv_list1"),
|
||||
test_id("conv_elem1"),
|
||||
),
|
||||
firstRecipient: Some(MailAddress {
|
||||
_id: None,
|
||||
name: "Bob".to_string(),
|
||||
address: "bob@example.com".to_string(),
|
||||
contact: None,
|
||||
_errors: Default::default(),
|
||||
}),
|
||||
mailDetails: None,
|
||||
mailDetailsDraft: None,
|
||||
bucketKey: None,
|
||||
sets: vec![],
|
||||
clientSpamClassifierResult: None,
|
||||
_errors: Default::default(),
|
||||
};
|
||||
|
||||
let rfc = mail_to_rfc2822(&mail, None);
|
||||
|
||||
assert!(rfc.contains("Date: Wed, 25 Dec 2024 12:37:25 +0000\r\n"));
|
||||
assert!(rfc.contains("From: Alice <alice@tuta.com>\r\n"));
|
||||
assert!(rfc.contains("Subject: Test Subject\r\n"));
|
||||
assert!(rfc.contains("To: Bob <bob@example.com>\r\n"));
|
||||
assert!(rfc.contains("MIME-Version: 1.0\r\n"));
|
||||
assert!(rfc.contains("Content-Type: text/html; charset=UTF-8\r\n"));
|
||||
assert!(rfc.contains("Content-Transfer-Encoding: base64\r\n"));
|
||||
assert!(rfc.contains("Message-ID: <"));
|
||||
// Body should be base64 of "<p>(No body available)</p>"
|
||||
assert!(rfc.contains("\r\n\r\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mail_to_rfc2822_with_details() {
|
||||
use tutasdk::date::DateTime;
|
||||
use tutasdk::entities::generated::tutanota::{Body, Recipients};
|
||||
use tutasdk::IdTupleGenerated;
|
||||
|
||||
let mail = Mail {
|
||||
_id: Some(IdTupleGenerated::new(
|
||||
test_id("list2"),
|
||||
test_id("elem2"),
|
||||
)),
|
||||
_permissions: test_id("perm2"),
|
||||
_format: 0,
|
||||
_ownerEncSessionKey: None,
|
||||
subject: "With Details".to_string(),
|
||||
receivedDate: DateTime::from_millis(0),
|
||||
state: 2,
|
||||
unread: true,
|
||||
confidential: false,
|
||||
replyType: 0,
|
||||
_ownerGroup: None,
|
||||
differentEnvelopeSender: None,
|
||||
listUnsubscribe: false,
|
||||
movedTime: None,
|
||||
phishingStatus: 0,
|
||||
authStatus: None,
|
||||
method: 0,
|
||||
recipientCount: 2,
|
||||
encryptionAuthStatus: None,
|
||||
_ownerKeyVersion: None,
|
||||
processingState: 0,
|
||||
processNeeded: false,
|
||||
sendAt: None,
|
||||
serverClassificationData: None,
|
||||
_kdfNonce: None,
|
||||
sender: MailAddress {
|
||||
_id: None,
|
||||
name: "".to_string(),
|
||||
address: "sender@tuta.com".to_string(),
|
||||
contact: None,
|
||||
_errors: Default::default(),
|
||||
},
|
||||
attachments: vec![],
|
||||
conversationEntry: IdTupleGenerated::new(
|
||||
test_id("conv_list2"),
|
||||
test_id("conv_elem2"),
|
||||
),
|
||||
firstRecipient: None,
|
||||
mailDetails: None,
|
||||
mailDetailsDraft: None,
|
||||
bucketKey: None,
|
||||
sets: vec![],
|
||||
clientSpamClassifierResult: None,
|
||||
_errors: Default::default(),
|
||||
};
|
||||
|
||||
let details = MailDetails {
|
||||
_id: None,
|
||||
sentDate: DateTime::from_millis(0),
|
||||
authStatus: 0,
|
||||
replyTos: vec![],
|
||||
recipients: Recipients {
|
||||
_id: None,
|
||||
toRecipients: vec![
|
||||
MailAddress {
|
||||
_id: None,
|
||||
name: "Bob".to_string(),
|
||||
address: "bob@example.com".to_string(),
|
||||
contact: None,
|
||||
_errors: Default::default(),
|
||||
},
|
||||
MailAddress {
|
||||
_id: None,
|
||||
name: "".to_string(),
|
||||
address: "charlie@example.com".to_string(),
|
||||
contact: None,
|
||||
_errors: Default::default(),
|
||||
},
|
||||
],
|
||||
ccRecipients: vec![MailAddress {
|
||||
_id: None,
|
||||
name: "Dave".to_string(),
|
||||
address: "dave@example.com".to_string(),
|
||||
contact: None,
|
||||
_errors: Default::default(),
|
||||
}],
|
||||
bccRecipients: vec![],
|
||||
},
|
||||
headers: None,
|
||||
body: Body {
|
||||
_id: None,
|
||||
text: Some("<p>Hello World</p>".to_string()),
|
||||
compressedText: None,
|
||||
_errors: Default::default(),
|
||||
},
|
||||
};
|
||||
|
||||
let rfc = mail_to_rfc2822(&mail, Some(&details));
|
||||
|
||||
assert!(rfc.contains("From: sender@tuta.com\r\n"));
|
||||
assert!(rfc.contains("To: Bob <bob@example.com>, charlie@example.com\r\n"));
|
||||
assert!(rfc.contains("Cc: Dave <dave@example.com>\r\n"));
|
||||
// Body should be base64 of "<p>Hello World</p>"
|
||||
let body_b64 =
|
||||
base64::engine::general_purpose::STANDARD.encode(b"<p>Hello World</p>");
|
||||
assert!(rfc.contains(&body_b64));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
use std::sync::Arc;
|
||||
use base64::Engine;
|
||||
use log::{info, error, debug};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio_rustls::TlsAcceptor;
|
||||
|
||||
use crate::mail::parser::parse_rfc2822;
|
||||
use crate::tuta::MailBackend;
|
||||
|
||||
#[derive(Debug)]
|
||||
enum SmtpState {
|
||||
Init,
|
||||
Greeted,
|
||||
MailFrom(String),
|
||||
RcptTo { from: String, to: Vec<String> },
|
||||
#[allow(dead_code)]
|
||||
Data { from: String, to: Vec<String> },
|
||||
Quit,
|
||||
}
|
||||
|
||||
#[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);
|
||||
|
||||
loop {
|
||||
let (stream, addr) = listener.accept().await?;
|
||||
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, pw_hash).await {
|
||||
error!("SMTP connection error: {}", e);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("SMTP TLS handshake failed: {}", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
let mut state = SmtpState::Init;
|
||||
|
||||
writer.write_all(b"220 TutaBridge SMTP ready\r\n").await?;
|
||||
|
||||
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();
|
||||
let n = reader.read_line(&mut line).await?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
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;
|
||||
info!("SMTP: received message ({} bytes)", data_buf.len());
|
||||
|
||||
let envelope_to: Vec<String> = match &state {
|
||||
SmtpState::Data { to, .. } => to.clone(),
|
||||
_ => vec![],
|
||||
};
|
||||
let mut parsed = parse_rfc2822(&data_buf);
|
||||
let header_addrs: std::collections::HashSet<String> = parsed
|
||||
.to
|
||||
.iter()
|
||||
.chain(parsed.cc.iter())
|
||||
.chain(parsed.bcc.iter())
|
||||
.map(|(_, addr)| addr.to_lowercase())
|
||||
.collect();
|
||||
for rcpt in &envelope_to {
|
||||
if !header_addrs.contains(&rcpt.to_lowercase()) {
|
||||
parsed.bcc.push((String::new(), rcpt.clone()));
|
||||
}
|
||||
}
|
||||
match tuta.send_mail(&parsed).await {
|
||||
Ok(()) => {
|
||||
info!("SMTP: mail sent successfully via Tuta");
|
||||
writer.write_all(b"250 OK message sent\r\n").await?;
|
||||
}
|
||||
Err(e) => {
|
||||
error!("SMTP: failed to send via Tuta: {}", e);
|
||||
writer
|
||||
.write_all(b"451 Temporary failure\r\n")
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
state = SmtpState::Greeted;
|
||||
data_buf.clear();
|
||||
} else {
|
||||
let unstuffed = if line.starts_with("..") {
|
||||
&line[1..]
|
||||
} else {
|
||||
&line
|
||||
};
|
||||
data_buf.push_str(unstuffed);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let cmd = trimmed.split_whitespace().next().unwrap_or("").to_uppercase();
|
||||
let response = match cmd.as_str() {
|
||||
"EHLO" | "HELO" => {
|
||||
state = SmtpState::Greeted;
|
||||
"250-TutaBridge\r\n250-AUTH PLAIN LOGIN\r\n250-8BITMIME\r\n250 SIZE 26214400\r\n"
|
||||
.to_string()
|
||||
}
|
||||
"AUTH" => {
|
||||
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);
|
||||
state = SmtpState::MailFrom(from);
|
||||
"250 OK\r\n".to_string()
|
||||
}
|
||||
"RCPT" => {
|
||||
let to_addr = extract_address(trimmed);
|
||||
match &mut state {
|
||||
SmtpState::MailFrom(from) => {
|
||||
let from = from.clone();
|
||||
state = SmtpState::RcptTo {
|
||||
from,
|
||||
to: vec![to_addr],
|
||||
};
|
||||
}
|
||||
SmtpState::RcptTo { to, .. } => {
|
||||
to.push(to_addr);
|
||||
}
|
||||
_ => {
|
||||
writer.write_all(b"503 Bad sequence\r\n").await?;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
"250 OK\r\n".to_string()
|
||||
}
|
||||
"DATA" => {
|
||||
match &state {
|
||||
SmtpState::RcptTo { from, to } => {
|
||||
state = SmtpState::Data {
|
||||
from: from.clone(),
|
||||
to: to.clone(),
|
||||
};
|
||||
in_data = true;
|
||||
"354 Start mail input; end with <CRLF>.<CRLF>\r\n".to_string()
|
||||
}
|
||||
_ => "503 Bad sequence\r\n".to_string(),
|
||||
}
|
||||
}
|
||||
"RSET" => {
|
||||
state = SmtpState::Greeted;
|
||||
"250 OK\r\n".to_string()
|
||||
}
|
||||
"QUIT" => {
|
||||
state = SmtpState::Quit;
|
||||
"221 BYE\r\n".to_string()
|
||||
}
|
||||
"NOOP" => "250 OK\r\n".to_string(),
|
||||
_ => "502 Command not implemented\r\n".to_string(),
|
||||
};
|
||||
|
||||
debug!("SMTP S: {}", response.trim_end());
|
||||
writer.write_all(response.as_bytes()).await?;
|
||||
|
||||
if matches!(state, SmtpState::Quit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn extract_address(line: &str) -> String {
|
||||
if let Some(start) = line.find('<') {
|
||||
if let Some(end) = line.find('>') {
|
||||
if start < end {
|
||||
return line[start + 1..end].to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
line.split(':')
|
||||
.nth(1)
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.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::*;
|
||||
|
||||
#[test]
|
||||
fn test_extract_address_angle_brackets() {
|
||||
assert_eq!(
|
||||
extract_address("MAIL FROM:<alice@example.com>"),
|
||||
"alice@example.com"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_address_rcpt_to() {
|
||||
assert_eq!(
|
||||
extract_address("RCPT TO:<bob@example.com>"),
|
||||
"bob@example.com"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_address_no_brackets() {
|
||||
assert_eq!(
|
||||
extract_address("MAIL FROM:alice@example.com"),
|
||||
"alice@example.com"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_address_with_spaces() {
|
||||
assert_eq!(
|
||||
extract_address("MAIL FROM: <alice@example.com>"),
|
||||
"alice@example.com"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_address_empty() {
|
||||
assert_eq!(extract_address("MAIL FROM:<>"), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_address_no_colon() {
|
||||
assert_eq!(extract_address("NOOP"), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_address_malformed_brackets() {
|
||||
let result = extract_address("MAIL FROM:>bad<");
|
||||
assert_eq!(result, ">bad<");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dot_unstuffing() {
|
||||
let line = "..This line started with a dot\r\n";
|
||||
let unstuffed = if line.starts_with("..") {
|
||||
&line[1..]
|
||||
} else {
|
||||
line
|
||||
};
|
||||
assert_eq!(unstuffed, ".This line started with a dot\r\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_dot_unstuffing_for_normal_lines() {
|
||||
let line = "Normal line\r\n";
|
||||
let unstuffed = if line.starts_with("..") {
|
||||
&line[1..]
|
||||
} else {
|
||||
line
|
||||
};
|
||||
assert_eq!(unstuffed, "Normal line\r\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_single_dot_not_unstuffed() {
|
||||
let line = ".other\r\n";
|
||||
let unstuffed = if line.starts_with("..") {
|
||||
&line[1..]
|
||||
} else {
|
||||
line
|
||||
};
|
||||
assert_eq!(unstuffed, ".other\r\n");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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, ¤t_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))
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tokio_rustls::rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer};
|
||||
use tokio_rustls::rustls::ServerConfig;
|
||||
use tokio_rustls::TlsAcceptor;
|
||||
|
||||
fn cert_dir() -> PathBuf {
|
||||
dirs::config_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join("tutabridge")
|
||||
}
|
||||
|
||||
fn cert_path() -> PathBuf {
|
||||
cert_dir().join("cert.pem")
|
||||
}
|
||||
|
||||
fn key_path() -> PathBuf {
|
||||
cert_dir().join("key.pem")
|
||||
}
|
||||
|
||||
pub fn load_or_create_tls_acceptor() -> Result<TlsAcceptor, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let cert_file = cert_path();
|
||||
let key_file = key_path();
|
||||
|
||||
let (cert_pem, key_pem) = if cert_file.exists() && key_file.exists() {
|
||||
log::info!("Loading TLS certificate from {}", cert_file.display());
|
||||
(std::fs::read_to_string(&cert_file)?, std::fs::read_to_string(&key_file)?)
|
||||
} else {
|
||||
log::info!("Generating self-signed TLS certificate...");
|
||||
let (cert, key) = generate_self_signed()?;
|
||||
std::fs::create_dir_all(cert_dir())?;
|
||||
std::fs::write(&cert_file, &cert)?;
|
||||
std::fs::write(&key_file, &key)?;
|
||||
log::info!("Certificate saved to {}", cert_file.display());
|
||||
(cert, key)
|
||||
};
|
||||
|
||||
let certs = load_certs(&cert_pem)?;
|
||||
let key = load_key(&key_pem)?;
|
||||
|
||||
let config = ServerConfig::builder()
|
||||
.with_no_client_auth()
|
||||
.with_single_cert(certs, key)?;
|
||||
|
||||
Ok(TlsAcceptor::from(Arc::new(config)))
|
||||
}
|
||||
|
||||
fn generate_self_signed() -> Result<(String, String), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let mut params = rcgen::CertificateParams::new(vec!["localhost".to_string()])?;
|
||||
params.subject_alt_names = vec![
|
||||
rcgen::SanType::DnsName("localhost".try_into()?),
|
||||
rcgen::SanType::IpAddress(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)),
|
||||
];
|
||||
let key_pair = rcgen::KeyPair::generate()?;
|
||||
let cert = params.self_signed(&key_pair)?;
|
||||
|
||||
Ok((cert.pem(), key_pair.serialize_pem()))
|
||||
}
|
||||
|
||||
fn load_certs(
|
||||
pem: &str,
|
||||
) -> Result<Vec<CertificateDer<'static>>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let mut reader = std::io::BufReader::new(pem.as_bytes());
|
||||
let certs: Vec<CertificateDer<'static>> = rustls_pemfile::certs(&mut reader)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
if certs.is_empty() {
|
||||
return Err("No certificates found in PEM".into());
|
||||
}
|
||||
Ok(certs)
|
||||
}
|
||||
|
||||
fn load_key(
|
||||
pem: &str,
|
||||
) -> Result<PrivateKeyDer<'static>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let mut reader = std::io::BufReader::new(pem.as_bytes());
|
||||
let keys: Vec<PrivatePkcs8KeyDer<'static>> = rustls_pemfile::pkcs8_private_keys(&mut reader)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let key = keys
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or("No private key found in PEM")?;
|
||||
Ok(PrivateKeyDer::Pkcs8(key))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_tls_acceptor_builds() {
|
||||
let _ = tokio_rustls::rustls::crypto::ring::default_provider().install_default();
|
||||
let (cert_pem, key_pem) = generate_self_signed().unwrap();
|
||||
let certs = load_certs(&cert_pem).unwrap();
|
||||
let key = load_key(&key_pem).unwrap();
|
||||
let config = ServerConfig::builder()
|
||||
.with_no_client_auth()
|
||||
.with_single_cert(certs, key);
|
||||
assert!(config.is_ok());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,642 @@
|
||||
use base64::Engine;
|
||||
use std::sync::Arc;
|
||||
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,
|
||||
MailSetEntry, SendDraftData, SendDraftParameters,
|
||||
};
|
||||
use tutasdk::folder_system::{FolderSystem, MailSetKind};
|
||||
use tutasdk::services::generated::tutanota::{DraftService, SendDraftService};
|
||||
use tutasdk::services::ExtraServiceParams;
|
||||
use tutasdk::{ApiCallError, CustomId, IdTupleGenerated, ListLoadDirection, LoggedInSdk, Sdk};
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::mail::ParsedMessage;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait MailBackend: Send + Sync {
|
||||
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>;
|
||||
async fn trash_mails(&self, mail_ids: Vec<IdTupleGenerated>) -> Result<(), String>;
|
||||
async fn send_mail(&self, msg: &ParsedMessage) -> Result<(), String>;
|
||||
}
|
||||
|
||||
pub struct TutaSession {
|
||||
pub logged_in: Arc<LoggedInSdk>,
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
impl TutaSession {
|
||||
pub async fn load_mailbox(&self) -> Result<MailBox, ApiCallError> {
|
||||
self.logged_in.mail_facade().load_user_mailbox().await
|
||||
}
|
||||
|
||||
pub async fn load_folders(&self, mailbox: &MailBox) -> Result<FolderSystem, ApiCallError> {
|
||||
self.logged_in
|
||||
.mail_facade()
|
||||
.load_folders_for_mailbox(mailbox)
|
||||
.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()
|
||||
}
|
||||
|
||||
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?;
|
||||
let folder = folders
|
||||
.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(),
|
||||
count,
|
||||
ListLoadDirection::DESC,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 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 {
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(mails)
|
||||
}
|
||||
|
||||
async fn load_mail_details_impl(
|
||||
&self,
|
||||
mail: &Mail,
|
||||
) -> Result<Option<MailDetails>, ApiCallError> {
|
||||
if mail.mailDetails.is_some() {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_mail_impl(&self, msg: &ParsedMessage) -> Result<(), ApiCallError> {
|
||||
let randomizer = RandomizerFacade::from_core(rand_core::OsRng);
|
||||
let session_key: GenericAesKey = Aes256Key::generate(&randomizer).into();
|
||||
|
||||
let mail_group_id = self
|
||||
.logged_in
|
||||
.mail_facade()
|
||||
.get_group_id_for_mail_address(&self.email)
|
||||
.await?;
|
||||
let group_key = self
|
||||
.logged_in
|
||||
.get_current_sym_group_key(&mail_group_id)
|
||||
.await?;
|
||||
|
||||
let owner_enc_session_key =
|
||||
group_key.object.encrypt_key(&session_key, Iv::generate(&randomizer));
|
||||
let owner_key_version = group_key.version as i64;
|
||||
|
||||
let draft_data = build_draft_data(msg, &self.email);
|
||||
|
||||
let create_data = DraftCreateData {
|
||||
_format: 0,
|
||||
previousMessageId: None,
|
||||
conversationType: 0,
|
||||
ownerEncSessionKey: owner_enc_session_key,
|
||||
ownerKeyVersion: owner_key_version,
|
||||
draftData: draft_data,
|
||||
_errors: Default::default(),
|
||||
};
|
||||
|
||||
let executor = self.logged_in.get_service_executor();
|
||||
let draft_return = executor
|
||||
.post::<DraftService>(
|
||||
create_data,
|
||||
ExtraServiceParams {
|
||||
session_key: Some(session_key.clone()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
log::info!("Draft created: {:?}", draft_return.draft);
|
||||
|
||||
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())
|
||||
.await?;
|
||||
|
||||
log::info!("Mail sent, message_id: {}", send_return.messageId);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MailBackend for TutaSession {
|
||||
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}"))
|
||||
}
|
||||
|
||||
async fn load_mail_details(&self, mail: &Mail) -> Result<Option<MailDetails>, String> {
|
||||
self.load_mail_details_impl(mail)
|
||||
.await
|
||||
.map_err(|e| format!("{e}"))
|
||||
}
|
||||
|
||||
async fn load_folder_list(&self) -> Result<Vec<(String, String)>, String> {
|
||||
let mailbox = self.load_mailbox().await.map_err(|e| format!("{e}"))?;
|
||||
let folder_system = self.load_folders(&mailbox).await.map_err(|e| format!("{e}"))?;
|
||||
|
||||
let known_folders = [
|
||||
(MailSetKind::Inbox, "INBOX", ""),
|
||||
(MailSetKind::Sent, "Sent", "\\Sent"),
|
||||
(MailSetKind::Draft, "Drafts", "\\Drafts"),
|
||||
(MailSetKind::Trash, "Trash", "\\Trash"),
|
||||
(MailSetKind::Archive, "Archive", "\\Archive"),
|
||||
(MailSetKind::Spam, "Spam", "\\Junk"),
|
||||
];
|
||||
|
||||
let mut result = Vec::new();
|
||||
for (kind, name, flags) in &known_folders {
|
||||
if folder_system.system_folder_by_type(*kind).is_some() {
|
||||
result.push((name.to_string(), flags.to_string()));
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn set_unread_status(
|
||||
&self,
|
||||
mail_ids: Vec<IdTupleGenerated>,
|
||||
unread: bool,
|
||||
) -> Result<(), String> {
|
||||
self.logged_in
|
||||
.mail_facade()
|
||||
.set_unread_status_for_mails(mail_ids, unread)
|
||||
.await
|
||||
.map_err(|e| format!("{e}"))
|
||||
}
|
||||
|
||||
async fn trash_mails(&self, mail_ids: Vec<IdTupleGenerated>) -> Result<(), String> {
|
||||
self.logged_in
|
||||
.mail_facade()
|
||||
.trash_mails(mail_ids)
|
||||
.await
|
||||
.map_err(|e| format!("{e}"))
|
||||
}
|
||||
|
||||
async fn send_mail(&self, msg: &ParsedMessage) -> Result<(), String> {
|
||||
self.send_mail_impl(msg).await.map_err(|e| format!("{e}"))
|
||||
}
|
||||
}
|
||||
|
||||
struct DiskFileClient {
|
||||
base_dir: std::path::PathBuf,
|
||||
}
|
||||
|
||||
impl DiskFileClient {
|
||||
fn new() -> Self {
|
||||
let base_dir = dirs::cache_dir()
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("."))
|
||||
.join("tutabridge");
|
||||
std::fs::create_dir_all(&base_dir).ok();
|
||||
Self { base_dir }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl FileClient for DiskFileClient {
|
||||
async fn persist_content(&self, name: String, content: Vec<u8>) -> Result<(), FileClientError> {
|
||||
let path = self.base_dir.join(&name);
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| FileClientError::from(e.kind()))?;
|
||||
}
|
||||
std::fs::write(&path, &content).map_err(|e| FileClientError::from(e.kind()))
|
||||
}
|
||||
|
||||
async fn read_content(&self, name: String) -> Result<Vec<u8>, FileClientError> {
|
||||
let path = self.base_dir.join(&name);
|
||||
std::fs::read(&path).map_err(|e| FileClientError::from(e.kind()))
|
||||
}
|
||||
}
|
||||
|
||||
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());
|
||||
let sdk = Sdk::new(cfg.api_url.clone(), rest_client, file_client);
|
||||
|
||||
if let Some(credentials) = load_credentials(&cfg.email) {
|
||||
log::info!("Resuming saved session...");
|
||||
match sdk.login(credentials).await {
|
||||
Ok(logged_in) => {
|
||||
return Ok(TutaSession {
|
||||
logged_in,
|
||||
email: cfg.email.clone(),
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Session expired, re-authenticating: {e}");
|
||||
delete_credentials(&cfg.email);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let password = password.ok_or("No saved session and no password provided")?;
|
||||
|
||||
log::info!("Authenticating with Tuta servers...");
|
||||
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.challenges.is_empty() {
|
||||
for c in &session.challenges {
|
||||
log::info!("2FA challenge: type={}, id={:?}", c.r#type, c._id);
|
||||
}
|
||||
|
||||
let has_totp = session
|
||||
.challenges
|
||||
.iter()
|
||||
.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 = 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}"))
|
||||
})?;
|
||||
|
||||
let mut cleared = false;
|
||||
for _ in 0..30 {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
let pending = sdk
|
||||
.is_second_factor_pending(&access_token)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
Box::<dyn std::error::Error + Send + Sync>::from(format!("2FA poll failed: {e}"))
|
||||
})?;
|
||||
if !pending {
|
||||
cleared = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !cleared {
|
||||
return Err("2FA verification timed out after 30 seconds".into());
|
||||
}
|
||||
}
|
||||
|
||||
let logged_in = sdk.login(credentials.clone()).await.map_err(|e| {
|
||||
Box::<dyn std::error::Error + Send + Sync>::from(format!("Login failed: {e}"))
|
||||
})?;
|
||||
|
||||
save_credentials(&cfg.email, &credentials);
|
||||
|
||||
Ok(TutaSession {
|
||||
logged_in,
|
||||
email: cfg.email.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
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,
|
||||
"user_id": creds.user_id.0,
|
||||
"access_token": creds.access_token,
|
||||
"encrypted_passphrase_key": base64::Engine::encode(
|
||||
&base64::engine::general_purpose::STANDARD,
|
||||
&creds.encrypted_passphrase_key,
|
||||
),
|
||||
"credential_type": match creds.credential_type {
|
||||
tutasdk::login::CredentialType::Internal => "Internal",
|
||||
tutasdk::login::CredentialType::External => "External",
|
||||
},
|
||||
});
|
||||
|
||||
match keyring::Entry::new(KEYRING_SERVICE, email) {
|
||||
Ok(entry) => {
|
||||
if let Err(e) = entry.set_password(&data.to_string()) {
|
||||
log::warn!("Failed to save session to keychain: {e}");
|
||||
} else {
|
||||
log::info!("Session saved to keychain");
|
||||
}
|
||||
}
|
||||
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(),
|
||||
user_id: tutasdk::GeneratedId(v["user_id"].as_str()?.to_string()),
|
||||
access_token: v["access_token"].as_str()?.to_string(),
|
||||
encrypted_passphrase_key: base64::Engine::decode(
|
||||
&base64::engine::general_purpose::STANDARD,
|
||||
v["encrypted_passphrase_key"].as_str()?,
|
||||
)
|
||||
.ok()?,
|
||||
credential_type: match v["credential_type"].as_str()? {
|
||||
"External" => tutasdk::login::CredentialType::External,
|
||||
_ => tutasdk::login::CredentialType::Internal,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn delete_credentials(email: &str) {
|
||||
|
||||
if let Ok(entry) = keyring::Entry::new(KEYRING_SERVICE, email) {
|
||||
let _ = entry.delete_credential();
|
||||
}
|
||||
*CREDENTIALS_CACHE.lock().unwrap() = None;
|
||||
}
|
||||
|
||||
|
||||
/// 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()
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user