diff --git a/crates/bridge/src/imap/mod.rs b/crates/bridge/src/imap/mod.rs index 7647115..62d784d 100644 --- a/crates/bridge/src/imap/mod.rs +++ b/crates/bridge/src/imap/mod.rs @@ -4,7 +4,7 @@ mod utf7; use log::{debug, error, info}; use std::sync::Arc; -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; use tokio::net::TcpListener; use tokio::sync::watch; use tokio_rustls::TlsAcceptor; @@ -115,6 +115,15 @@ async fn handle_connection( let trimmed = line.trim_end(); debug!("IMAP C: {}", trimmed); + // APPEND carries a message literal the line-based session layer cannot + // read, so it is handled here at the socket level. + if !session.is_awaiting_auth() { + if let Some(req) = session::parse_append(trimmed) { + handle_append(&mut reader, &mut writer, &session, req).await?; + continue; + } + } + let responses = if session.is_awaiting_auth() { session.handle_auth_response(trimmed) } else { @@ -133,3 +142,197 @@ async fn handle_connection( Ok(()) } + +/// Largest APPEND message literal we will read into memory. +const MAX_APPEND_BYTES: usize = 26_214_400; + +/// Handle an `APPEND`. Tuta saves sent mail server-side, so an APPEND to the +/// Sent folder is accepted as a no-op: read and discard the literal, reply OK. +/// That lets a mail client's "save a copy to Sent" succeed without creating a +/// duplicate (the real copy arrives via the syncer). Other folders are not +/// supported yet and are rejected before the literal is sent, so the client +/// aborts the synchronizing literal and the stream stays in sync. +async fn handle_append( + reader: &mut R, + writer: &mut W, + session: &ImapSession, + req: session::AppendRequest, +) -> Result<(), Box> +where + R: AsyncBufRead + Unpin, + W: AsyncWriteExt + Unpin, +{ + if !session.append_targets_sent(&req.mailbox).await { + let resp = format!( + "{} NO [CANNOT] APPEND is only supported for the Sent folder; Tuta saves sent mail automatically\r\n", + req.tag + ); + writer.write_all(resp.as_bytes()).await?; + writer.flush().await?; + return Ok(()); + } + if req.literal_size > MAX_APPEND_BYTES { + let resp = format!("{} NO message too large\r\n", req.tag); + writer.write_all(resp.as_bytes()).await?; + writer.flush().await?; + return Ok(()); + } + + // Synchronizing literal: tell the client to send the message, then read and + // discard it plus the trailing CRLF (the real Sent copy comes from sync). + writer.write_all(b"+ OK\r\n").await?; + writer.flush().await?; + let mut buf = vec![0u8; req.literal_size]; + reader.read_exact(&mut buf).await?; + let mut tail = String::new(); + reader.read_line(&mut tail).await?; + + let resp = format!("{} OK APPEND completed\r\n", req.tag); + writer.write_all(resp.as_bytes()).await?; + writer.flush().await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mail::parser::ParsedMessage; + use crate::tuta::FolderInfo; + use tokio::io::AsyncReadExt; + use tutasdk::entities::generated::tutanota::{Mail, MailDetails, MailSetEntry, TutanotaFile}; + use tutasdk::folder_system::MailSetKind; + use tutasdk::IdTupleGenerated; + + struct NoopBackend; + #[async_trait::async_trait] + impl MailBackend for NoopBackend { + async fn load_mail_ids_for_folder( + &self, + _f: &FolderInfo, + _l: usize, + ) -> Result, String> { + unimplemented!() + } + async fn load_mail(&self, _l: &str, _e: &str) -> Result, String> { + unimplemented!() + } + async fn decrypt_inline_mail(&self, _j: &str) -> Result, String> { + unimplemented!() + } + async fn decrypt_inline_mail_set_entry( + &self, + _j: &str, + ) -> Result, String> { + unimplemented!() + } + async fn decrypt_inline_mail_details_blob( + &self, + _j: &str, + ) -> Result, String> { + unimplemented!() + } + async fn load_mail_details(&self, _m: &Mail) -> Result, String> { + unimplemented!() + } + async fn load_attachments( + &self, + _m: &Mail, + ) -> Result)>, String> { + unimplemented!() + } + async fn list_folders(&self) -> Result, String> { + unimplemented!() + } + async fn set_unread_status( + &self, + _ids: Vec, + _u: bool, + ) -> Result<(), String> { + unimplemented!() + } + async fn trash_mails(&self, _ids: Vec) -> Result<(), String> { + unimplemented!() + } + async fn move_mails( + &self, + _ids: Vec, + _t: &FolderInfo, + ) -> Result<(), String> { + unimplemented!() + } + async fn send_mail(&self, _m: &ParsedMessage) -> Result<(), String> { + unimplemented!() + } + } + + async fn session_with_sent() -> ImapSession { + let store = MailStore::new(); + let sent = FolderInfo { + id: "sent".into(), + list_id: "folders".into(), + entries_list_id: "se".into(), + kind: MailSetKind::Sent, + imap_path: "Sent".into(), + special_use: Some("\\Sent".into()), + }; + store.set_folder_list(vec![sent]).await; + ImapSession::new(store, Arc::new(NoopBackend), None, None) + } + + #[tokio::test] + async fn append_to_sent_reads_literal_and_returns_ok() { + let session = session_with_sent().await; + let (mut client, server) = tokio::io::duplex(4096); + let (sr, mut sw) = tokio::io::split(server); + let mut reader = BufReader::new(sr); + let req = session::AppendRequest { + tag: "a1".into(), + mailbox: "Sent".into(), + literal_size: 5, + }; + + let server_fut = handle_append(&mut reader, &mut sw, &session, req); + let client_fut = async { + let mut buf = [0u8; 32]; + let n = client.read(&mut buf).await.unwrap(); + assert!( + String::from_utf8_lossy(&buf[..n]).starts_with('+'), + "expected a continuation request" + ); + client.write_all(b"hello\r\n").await.unwrap(); + let mut resp = vec![0u8; 128]; + let n = client.read(&mut resp).await.unwrap(); + String::from_utf8_lossy(&resp[..n]).into_owned() + }; + let (res, resp) = tokio::join!(server_fut, client_fut); + res.unwrap(); + assert!(resp.contains("a1 OK APPEND completed"), "got {resp:?}"); + } + + #[tokio::test] + async fn append_to_non_sent_is_rejected_without_continuation() { + let session = session_with_sent().await; + let (mut client, server) = tokio::io::duplex(4096); + let (sr, mut sw) = tokio::io::split(server); + let mut reader = BufReader::new(sr); + let req = session::AppendRequest { + tag: "b2".into(), + mailbox: "Drafts".into(), + literal_size: 5, + }; + + let server_fut = handle_append(&mut reader, &mut sw, &session, req); + let client_fut = async { + let mut resp = vec![0u8; 128]; + let n = client.read(&mut resp).await.unwrap(); + String::from_utf8_lossy(&resp[..n]).into_owned() + }; + let (res, resp) = tokio::join!(server_fut, client_fut); + res.unwrap(); + assert!(resp.contains("b2 NO"), "got {resp:?}"); + assert!( + !resp.contains('+'), + "a rejected folder must not get a continuation" + ); + } +} diff --git a/crates/bridge/src/imap/session.rs b/crates/bridge/src/imap/session.rs index d1e4770..31455e0 100644 --- a/crates/bridge/src/imap/session.rs +++ b/crates/bridge/src/imap/session.rs @@ -234,6 +234,19 @@ impl ImapSession { } } + /// `true` if `mailbox` resolves to the Sent folder. Tuta saves sent mail + /// server-side, so an `APPEND` there is a no-op (see `handle_append` in the + /// connection loop), which is what lets a client's "save to Sent" succeed + /// without creating a duplicate. + pub(crate) async fn append_targets_sent(&self, mailbox: &str) -> bool { + let name = super::utf7::decode(mailbox).unwrap_or_else(|| mailbox.to_string()); + self.store + .folder_by_imap_path(&name) + .await + .map(|f| f.special_use.as_deref() == Some("\\Sent")) + .unwrap_or(false) + } + fn cmd_capability(&self, tag: &str) -> Vec { vec![ "* CAPABILITY IMAP4rev1 AUTH=PLAIN IDLE NAMESPACE UIDPLUS MOVE\r\n".to_string(), @@ -1061,6 +1074,50 @@ fn parse_command(line: &str) -> (String, String, String) { (tag, cmd, args) } +/// A parsed `APPEND` command: the tag, the target mailbox, and the size of the +/// trailing `{N}` message literal the client is about to send. +pub(crate) struct AppendRequest { + pub tag: String, + pub mailbox: String, + pub literal_size: usize, +} + +/// Parse an `APPEND` command line. Returns `None` if it is not a well-formed +/// APPEND (so the caller falls back to normal command handling). The literal +/// reading itself happens at the socket level, since the session is line based. +pub(crate) fn parse_append(line: &str) -> Option { + let (tag, cmd, args) = parse_command(line); + if !cmd.eq_ignore_ascii_case("APPEND") { + return None; + } + let mailbox = append_first_token(&args)?; + let literal_size = append_literal_size(&args)?; + Some(AppendRequest { + tag, + mailbox, + literal_size, + }) +} + +/// The first argument token of an APPEND (the mailbox), honoring quoting. +fn append_first_token(args: &str) -> Option { + let s = args.trim_start(); + if let Some(rest) = s.strip_prefix('"') { + let end = rest.find('"')?; + Some(rest[..end].to_string()) + } else { + let end = s.find(char::is_whitespace).unwrap_or(s.len()); + (end > 0).then(|| s[..end].to_string()) + } +} + +/// The size declared by the trailing `{N}` (or non-sync `{N+}`) literal. +fn append_literal_size(args: &str) -> Option { + let open = args.rfind('{')?; + let close = args[open..].find('}')? + open; + args[open + 1..close].trim_end_matches('+').parse::().ok() +} + #[cfg(test)] mod tests { use super::*; @@ -1586,6 +1643,47 @@ mod tests { ); } + #[test] + fn parse_append_extracts_mailbox_and_size() { + let r = parse_append("a1 APPEND \"Sent\" (\\Seen) {310}").unwrap(); + assert_eq!(r.tag, "a1"); + assert_eq!(r.mailbox, "Sent"); + assert_eq!(r.literal_size, 310); + + // Bare (unquoted) mailbox, no flags. + let r = parse_append("x APPEND Drafts {42}").unwrap(); + assert_eq!(r.mailbox, "Drafts"); + assert_eq!(r.literal_size, 42); + + // Non-synchronizing literal {N+}. + let r = parse_append("y APPEND \"Sent\" {7+}").unwrap(); + assert_eq!(r.literal_size, 7); + + // Not an APPEND, or missing the literal. + assert!(parse_append("z SELECT INBOX").is_none()); + assert!(parse_append("z APPEND Sent").is_none()); + } + + #[tokio::test] + async fn append_targets_sent_only_for_the_sent_folder() { + let backend = Arc::new(MockBackend::with_mails(vec![])); + let store = MailStore::new(); + let sent = FolderInfo { + id: "sent".into(), + list_id: "folders".into(), + entries_list_id: "sent_entries".into(), + kind: MailSetKind::Sent, + imap_path: "Sent".into(), + special_use: Some("\\Sent".into()), + }; + store.set_folder_list(vec![inbox_folder(), sent]).await; + let session = ImapSession::new(store, backend, None, None); + + assert!(session.append_targets_sent("Sent").await); + assert!(!session.append_targets_sent("INBOX").await); + assert!(!session.append_targets_sent("Nonexistent").await); + } + async fn populate_store(store: &MailStore, mails: &[Mail]) { store.set_folder_list(vec![inbox_folder()]).await; let stored: Vec = mails