imap: implement APPEND (no-op for Sent, reject other folders) (#11)

Mail clients save a copy of each sent message to the Sent folder with an
IMAP APPEND. The bridge did not implement APPEND, so Thunderbird reported
"a copy was not placed in your Sent folder" after every send.

Tuta saves sent mail server-side and the syncer brings that copy back, so
an APPEND to Sent is a no-op: read and discard the literal, reply OK,
which avoids creating a duplicate. APPEND to any other folder is rejected
before the literal is sent (the client then aborts the synchronizing
literal and the stream stays in sync); real APPEND-to-Drafts is left for
a follow-up.

The literal is read at the socket level since the session layer is line
based. Tested: parse_append, the Sent-folder decision, and the full
handle_append flow over an in-memory pipe (Sent reads the literal and
returns OK, a non-Sent folder is rejected with no continuation).
This commit is contained in:
Anthony M
2026-06-14 21:57:03 +02:00
committed by GitHub
parent 7da9339146
commit 2283a4cf15
2 changed files with 302 additions and 1 deletions
+204 -1
View File
@@ -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<R, W>(
reader: &mut R,
writer: &mut W,
session: &ImapSession,
req: session::AppendRequest,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>>
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<Vec<Mail>, String> {
unimplemented!()
}
async fn load_mail(&self, _l: &str, _e: &str) -> Result<Option<Mail>, String> {
unimplemented!()
}
async fn decrypt_inline_mail(&self, _j: &str) -> Result<Option<Mail>, String> {
unimplemented!()
}
async fn decrypt_inline_mail_set_entry(
&self,
_j: &str,
) -> Result<Option<MailSetEntry>, String> {
unimplemented!()
}
async fn decrypt_inline_mail_details_blob(
&self,
_j: &str,
) -> Result<Option<MailDetails>, String> {
unimplemented!()
}
async fn load_mail_details(&self, _m: &Mail) -> Result<Option<MailDetails>, String> {
unimplemented!()
}
async fn load_attachments(
&self,
_m: &Mail,
) -> Result<Vec<(TutanotaFile, Vec<u8>)>, String> {
unimplemented!()
}
async fn list_folders(&self) -> Result<Vec<FolderInfo>, String> {
unimplemented!()
}
async fn set_unread_status(
&self,
_ids: Vec<IdTupleGenerated>,
_u: bool,
) -> Result<(), String> {
unimplemented!()
}
async fn trash_mails(&self, _ids: Vec<IdTupleGenerated>) -> Result<(), String> {
unimplemented!()
}
async fn move_mails(
&self,
_ids: Vec<IdTupleGenerated>,
_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"
);
}
}
+98
View File
@@ -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<String> {
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<AppendRequest> {
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<String> {
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<usize> {
let open = args.rfind('{')?;
let close = args[open..].find('}')? + open;
args[open + 1..close].trim_end_matches('+').parse::<usize>().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<StoredMail> = mails