mirror of
https://github.com/spartanz51/tutabridge.git
synced 2026-06-24 10:54:32 +02:00
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
288 lines
8.5 KiB
Rust
288 lines
8.5 KiB
Rust
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");
|
|
}
|
|
}
|