Initial commit: TutaBridge IMAP/SMTP bridge for Tuta

Local bridge that exposes Tuta encrypted email via standard
IMAP/SMTP protocols for use with Thunderbird and other clients.

Features:
- IMAP server with TLS (STARTTLS self-signed cert)
- SMTP server for sending mail via Tuta
- Session persistence via macOS Keychain
- Mail body decryption including LZ4-compressed blobs
- Blob storage access (BlobAccessTokenService + blob server)
- RFC 2822 message formatting
- Interactive first-run configuration
This commit is contained in:
Anthony
2026-05-21 11:46:32 +02:00
commit 8595aedfad
13 changed files with 6882 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
/target
/tuta-repo
Generated
+3098
View File
File diff suppressed because it is too large Load Diff
+44
View File
@@ -0,0 +1,44 @@
[package]
name = "tutabridge"
version = "0.1.0"
edition = "2021"
rust-version = "1.84.0"
[dependencies]
# Tuta SDK with native HTTP client
tuta-sdk = { path = "tuta-repo/tuta-sdk/rust/sdk", features = ["net"] }
# Async runtime
tokio = { version = "1.43", features = ["full"] }
async-trait = "0.1"
# Logging
log = "0.4"
env_logger = "0.11"
# Config
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
toml = "0.8"
# TLS
tokio-rustls = { version = "0.26", features = ["ring"] }
rustls-pemfile = "2"
rcgen = "0.13"
# Crypto primitives for key generation
crypto-primitives = { path = "tuta-repo/tuta-sdk/rust/crypto-primitives" }
# Credentials storage
keyring = { version = "3", features = ["apple-native"] }
# Misc
thiserror = "2.0"
base64 = "0.22"
dirs = "6"
anyhow = "1"
rpassword = "7"
rand_core = "0.6"
[dev-dependencies]
tuta-sdk = { path = "tuta-repo/tuta-sdk/rust/sdk", features = ["net", "logging", "testing"] }
+156
View File
@@ -0,0 +1,156 @@
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,
}
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(),
}
}
}
fn config_path() -> PathBuf {
let dir = dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("tutabridge");
dir.join("config.toml")
}
pub fn load_or_create_config() -> Result<Config, Box<dyn std::error::Error>> {
let path = config_path();
let mut cfg = if path.exists() {
let content = std::fs::read_to_string(&path)?;
toml::from_str(&content)?
} else {
std::fs::create_dir_all(path.parent().unwrap())?;
Config::default()
};
if cfg.email.is_empty() {
use std::io::{BufRead, Write};
print!("Tuta email address: ");
std::io::stdout().flush()?;
let mut email = String::new();
std::io::stdin().lock().read_line(&mut email)?;
let email = email.trim().to_string();
if email.is_empty() {
return Err("Email address is required".into());
}
cfg.email = email;
let content = toml::to_string_pretty(&cfg)?;
std::fs::write(&path, &content)?;
}
Ok(cfg)
}
#[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(),
};
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());
}
}
+101
View File
@@ -0,0 +1,101 @@
mod session;
use std::sync::Arc;
use std::time::Duration;
use log::{info, error, debug};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::TcpListener;
use tokio_rustls::TlsAcceptor;
use crate::tuta::MailBackend;
use session::ImapSession;
pub async fn serve(port: u16, tuta: Arc<dyn MailBackend>, tls: TlsAcceptor) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let listener = TcpListener::bind(format!("127.0.0.1:{}", port)).await?;
info!("IMAP server listening on 127.0.0.1:{} (TLS)", port);
loop {
let (stream, addr) = listener.accept().await?;
debug!("IMAP connection from {}", addr);
let tuta = tuta.clone();
let tls = tls.clone();
tokio::spawn(async move {
match tls.accept(stream).await {
Ok(tls_stream) => {
if let Err(e) = handle_connection(tls_stream, tuta).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>,
tuta: Arc<dyn MailBackend>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let (reader, mut writer) = tokio::io::split(stream);
let mut reader = BufReader::new(reader);
let mut session = ImapSession::new(tuta);
writer.write_all(b"* OK TutaBridge IMAP4rev1 ready\r\n").await?;
let mut line = String::new();
loop {
if session.is_idle() {
let poll_interval = Duration::from_secs(30);
line.clear();
tokio::select! {
result = reader.read_line(&mut line) => {
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?;
}
}
}
_ = tokio::time::sleep(poll_interval) => {
let updates = session.check_new_mail().await;
for resp in &updates {
debug!("IMAP S (idle): {}", resp.trim_end());
writer.write_all(resp.as_bytes()).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 = session.handle_command(trimmed).await;
for resp in &responses {
debug!("IMAP S: {}", resp.trim_end());
writer.write_all(resp.as_bytes()).await?;
}
if session.is_logout() {
break;
}
}
Ok(())
}
+1542
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
pub(crate) mod rfc2822;
pub(crate) mod parser;
pub use rfc2822::mail_to_rfc2822;
pub use parser::ParsedMessage;
+536
View File
@@ -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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
#[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 &lt;world&gt;</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}");
}
}
+497
View File
@@ -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.text.as_deref().or(d.body.compressedText.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));
}
}
+52
View File
@@ -0,0 +1,52 @@
mod config;
mod tuta;
mod imap;
mod mail;
mod smtp;
mod tls;
use std::sync::Arc;
use log::info;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tokio_rustls::rustls::crypto::ring::default_provider()
.install_default()
.map_err(|_| anyhow::anyhow!("Failed to install TLS crypto provider"))?;
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
let cfg = config::load_or_create_config().map_err(|e| anyhow::anyhow!("{e}"))?;
info!("TutaBridge starting...");
let tls_acceptor = tls::load_or_create_tls_acceptor()
.map_err(|e| anyhow::anyhow!("TLS setup failed: {e}"))?;
info!("TLS initialized");
info!("IMAP will listen on 127.0.0.1:{}", cfg.imap_port);
info!("SMTP will listen on 127.0.0.1:{}", cfg.smtp_port);
let session = tuta::login(&cfg).await.map_err(|e| anyhow::anyhow!("{e}"))?;
let session: Arc<dyn tuta::MailBackend> = Arc::new(session);
info!("Logged in as {}", cfg.email);
let imap_session = session.clone();
let smtp_session = session.clone();
let imap_tls = tls_acceptor.clone();
let smtp_tls = tls_acceptor;
let imap_handle = tokio::spawn(imap::serve(cfg.imap_port, imap_session, imap_tls));
let smtp_handle = tokio::spawn(smtp::serve(cfg.smtp_port, smtp_session, smtp_tls));
info!("Bridge is running. Configure Thunderbird with:");
info!(" IMAP server: 127.0.0.1:{} (SSL/TLS)", cfg.imap_port);
info!(" SMTP server: 127.0.0.1:{} (SSL/TLS)", cfg.smtp_port);
info!(" Username: {}", cfg.email);
info!(" Password: (any password — bridge handles auth)");
info!(" Accept the self-signed certificate when prompted");
tokio::select! {
r = imap_handle => r.map_err(|e| anyhow::anyhow!("{e}"))?.map_err(|e| anyhow::anyhow!("{e}")),
r = smtp_handle => r.map_err(|e| anyhow::anyhow!("{e}"))?.map_err(|e| anyhow::anyhow!("{e}")),
}
}
+287
View File
@@ -0,0 +1,287 @@
use std::sync::Arc;
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,
}
pub async fn serve(port: u16, tuta: Arc<dyn MailBackend>, tls: TlsAcceptor) -> 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();
tokio::spawn(async move {
match tls.accept(stream).await {
Ok(tls_stream) => {
if let Err(e) = handle_connection(tls_stream, tuta).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>,
) -> 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;
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 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" => {
"235 2.7.0 Authentication successful\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()
}
#[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");
}
}
+100
View File
@@ -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());
}
}
+462
View File
@@ -0,0 +1,462 @@
use std::sync::Arc;
use crypto_primitives::aes::{Aes256Key, Iv};
use crypto_primitives::key::GenericAesKey;
use crypto_primitives::randomizer_facade::RandomizerFacade;
use tutasdk::bindings::file_client::{FileClient, FileClientError};
use tutasdk::bindings::rest_client::RestClient;
use tutasdk::crypto_entity_client::CryptoEntityClient;
use tutasdk::entities::generated::tutanota::{
DraftCreateData, DraftData, DraftRecipient, Mail, MailBox, MailDetails, MailDetailsBlob,
MailSetEntry, SendDraftData,
};
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) -> 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
}
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,
) -> 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 entries_list_id = &folder.entries;
let entries: Vec<MailSetEntry> = self
.crypto_client()
.load_range(
entries_list_id,
&CustomId::default(),
100,
ListLoadDirection::DESC,
)
.await?;
let mut mails = Vec::new();
for entry in &entries {
match self.crypto_client().load::<Mail, _>(&entry.mail).await {
Ok(mail) => mails.push(mail),
Err(e) => log::warn!("Failed to load mail {:?}: {}", entry.mail, e),
}
}
Ok(mails)
}
async fn load_mail_details_impl(
&self,
mail: &Mail,
) -> Result<Option<MailDetailsBlob>, ApiCallError> {
if mail.mailDetails.is_some() {
let blob = self.logged_in.load_mail_details_blob(mail).await?;
Ok(Some(blob))
} 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 to_recips: Vec<DraftRecipient> = msg
.to
.iter()
.map(|(name, addr)| DraftRecipient {
_id: None,
name: name.clone(),
mailAddress: addr.clone(),
_errors: Default::default(),
})
.collect();
let cc_recips: Vec<DraftRecipient> = msg
.cc
.iter()
.map(|(name, addr)| DraftRecipient {
_id: None,
name: name.clone(),
mailAddress: addr.clone(),
_errors: Default::default(),
})
.collect();
let bcc_recips: Vec<DraftRecipient> = msg
.bcc
.iter()
.map(|(name, addr)| DraftRecipient {
_id: None,
name: name.clone(),
mailAddress: addr.clone(),
_errors: Default::default(),
})
.collect();
let draft_data = DraftData {
_id: None,
subject: msg.subject.clone(),
bodyText: msg.body_html.clone(),
senderMailAddress: self.email.clone(),
senderName: msg.from_name.clone(),
confidential: false,
method: 0,
compressedBodyText: None,
toRecipients: to_recips,
ccRecipients: cc_recips,
bccRecipients: bcc_recips,
addedAttachments: vec![],
removedAttachments: vec![],
replyTos: vec![],
_errors: Default::default(),
};
let 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 send_data = SendDraftData {
_format: 0,
language: "en".to_string(),
mailSessionKey: Some(session_key.as_bytes().to_vec()),
bucketEncMailSessionKey: None,
senderNameUnencrypted: None,
plaintext: true,
calendarMethod: false,
sessionEncEncryptionAuthStatus: None,
sendAt: None,
allowUndo: false,
internalRecipientKeyData: vec![],
secureExternalRecipientKeyData: vec![],
attachmentKeyData: vec![],
mail: draft_return.draft,
symEncInternalRecipientKeyData: vec![],
parameters: None,
};
let 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) -> Result<Vec<Mail>, String> {
self.load_mail_ids_for_folder_impl(kind)
.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(|opt| opt.map(|blob| blob.details))
.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 async fn login(cfg: &Config) -> 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 = rpassword_prompt(&cfg.email)?;
log::info!("Authenticating with Tuta servers...");
let (session_return, credentials) = sdk
.initiate_session(&cfg.email, &password)
.await
.map_err(|e| {
Box::<dyn std::error::Error + Send + Sync>::from(format!("Login failed: {e}"))
})?;
if !session_return.challenges.is_empty() {
for c in &session_return.challenges {
log::info!("2FA challenge: type={}, id={:?}", c.r#type, c._id);
}
let has_totp = session_return
.challenges
.iter()
.any(|c| c.r#type == 1);
if !has_totp {
return Err("Account requires U2F/WebAuthn 2FA which is not supported — only TOTP is supported".into());
}
let totp_code = totp_prompt()?;
sdk.submit_2fa(&session_return.accessToken, totp_code)
.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
.check_2fa_pending(&session_return.accessToken)
.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";
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}"),
}
}
fn load_credentials(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();
}
}
fn rpassword_prompt(email: &str) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
use std::io::Write;
print!("Password for {}: ", email);
std::io::stdout().flush()?;
let password = rpassword::read_password()?;
Ok(password)
}
fn totp_prompt() -> Result<u32, Box<dyn std::error::Error + Send + Sync>> {
use std::io::{BufRead, Write};
print!("TOTP code: ");
std::io::stdout().flush()?;
let mut code_str = String::new();
std::io::stdin().lock().read_line(&mut code_str)?;
let code: u32 = code_str
.trim()
.parse()
.map_err(|_| "Invalid TOTP code — must be a number")?;
Ok(code)
}