diff --git a/SDK_PRS.md b/SDK_PRS.md index 7842af2..2ddf511 100644 --- a/SDK_PRS.md +++ b/SDK_PRS.md @@ -22,6 +22,7 @@ branch = one commit, rebasable on `upstream/master`. | `sdk-blob-element-reading` | `BlobFacade.load_blob_element` + `MailFacade.load_mail_details_blob` (read `MailDetailsBlob`) | [tutao#10870](https://github.com/tutao/tutanota/pull/10870) | — | yes (open) | no | yes (body decrypt over IMAP) | | `sdk-2fa-session` | Interactive 2FA: `initiate_session`, `authenticate_with_second_factor_totp`, `is_second_factor_pending`, `cancel_create_session` | [tutao#10871](https://github.com/tutao/tutanota/pull/10871) | — | yes (open) | no | yes (full TOTP login) | | `sdk-folder-system` | Rebuild `FolderSystem` tree (system/custom/nested), add `MailSetKind` Label/Imported/Scheduled + accessors | — | [spartanz51#4](https://github.com/spartanz51/tutanota/pull/4) | no (held) | no | yes (custom folders listed + read over IMAP) | +| `sdk-move-mails` | `MailFacade.move_mails` (move to an arbitrary folder via `MoveMailService`) | — | [spartanz51#5](https://github.com/spartanz51/tutanota/pull/5) | no (held) | no | yes (IMAP MOVE between folders) | ## Notes per branch diff --git a/crates/bridge/src/imap/session.rs b/crates/bridge/src/imap/session.rs index 7f79e3d..58ddcac 100644 --- a/crates/bridge/src/imap/session.rs +++ b/crates/bridge/src/imap/session.rs @@ -161,6 +161,8 @@ impl ImapSession { "EXPUNGE" => self.cmd_expunge(&tag).await, "SEARCH" => self.cmd_search(&tag, &args, false), "STORE" => self.cmd_store(&tag, &args, false).await, + "MOVE" => self.cmd_move(&tag, &args, false).await, + "COPY" => self.cmd_copy(&tag), "IDLE" => { self.idle_tag = Some(tag.clone()); vec!["+ idling\r\n".to_string()] @@ -175,7 +177,7 @@ impl ImapSession { fn cmd_capability(&self, tag: &str) -> Vec { vec![ - "* CAPABILITY IMAP4rev1 AUTH=PLAIN IDLE NAMESPACE UIDPLUS\r\n".to_string(), + "* CAPABILITY IMAP4rev1 AUTH=PLAIN IDLE NAMESPACE UIDPLUS MOVE\r\n".to_string(), format!("{} OK CAPABILITY completed\r\n", tag), ] } @@ -454,7 +456,8 @@ impl ImapSession { "FETCH" => self.cmd_fetch(tag, subargs, true).await, "SEARCH" => self.cmd_search(tag, subargs, true), "STORE" => self.cmd_store(tag, subargs, true).await, - "COPY" => vec![format!("{} OK UID COPY completed\r\n", tag)], + "MOVE" => self.cmd_move(tag, subargs, true).await, + "COPY" => self.cmd_copy(tag), _ => vec![format!("{} BAD Unknown UID subcommand\r\n", tag)], } } @@ -501,6 +504,76 @@ impl ImapSession { responses } + /// MOVE / UID MOVE (RFC 6851): move messages to another mailbox. Tuta + /// folders are exclusive, so this maps directly to a server-side move; the + /// messages are then expunged from the source view. + async fn cmd_move(&mut self, tag: &str, args: &str, uid_mode: bool) -> Vec { + let label = if uid_mode { "UID MOVE" } else { "MOVE" }; + if self.state != State::Selected { + return vec![format!("{} NO No mailbox selected\r\n", tag)]; + } + + let (seq_set, rest) = args.trim().split_once(' ').unwrap_or((args.trim(), "")); + let (raw_name, _) = parse_imap_token(rest.trim()); + let folder_name = super::utf7::decode(&raw_name).unwrap_or_else(|| raw_name.clone()); + let target = match self.store.folder_by_imap_path(&folder_name).await { + Some(f) => f, + None => return vec![format!("{} NO [TRYCREATE] Mailbox does not exist\r\n", tag)], + }; + + let indices = self.resolve_sequence_set(seq_set, uid_mode); + let mail_ids: Vec<_> = indices + .iter() + .filter_map(|&i| self.mails.get(i)) + .filter_map(|m| m.mail._id.clone()) + .collect::>(); + + if mail_ids.is_empty() { + return vec![format!("{} OK {} completed\r\n", tag, label)]; + } + + let moved: std::collections::HashSet = mail_ids + .iter() + .map(|id| id.element_id.to_string()) + .collect(); + + if let Err(e) = self.backend.move_mails(mail_ids, &target).await { + log::warn!("{} to {} failed: {}", label, folder_name, e); + return vec![format!("{} NO {} failed\r\n", tag, label)]; + } + + // Expunge the moved messages from the source view (RFC 6851). + let mut responses = Vec::new(); + let mut seq = 1u32; + let mut i = 0; + while i < self.mails.len() { + let is_moved = self.mails[i] + .mail + ._id + .as_ref() + .map(|id| moved.contains(&id.element_id.to_string())) + .unwrap_or(false); + if is_moved { + responses.push(format!("* {} EXPUNGE\r\n", seq)); + self.mails.remove(i); + } else { + seq += 1; + i += 1; + } + } + responses.push(format!("{} OK {} completed\r\n", tag, label)); + responses + } + + /// COPY is not supported: Tuta folders are exclusive (no duplication). + /// Clients should use MOVE, which we advertise. + fn cmd_copy(&self, tag: &str) -> Vec { + vec![format!( + "{} NO [CANNOT] COPY is not supported; use MOVE\r\n", + tag + )] + } + async fn refresh_mails(&mut self, folder_id: &str) -> Result<(), String> { let stored = self.store.get_folder(folder_id).await; @@ -1206,6 +1279,7 @@ mod tests { trashed: Mutex>, unread_calls: Mutex, bool)>>, sent: Mutex>, + moved: Mutex, String)>>, } impl MockBackend { @@ -1216,6 +1290,7 @@ mod tests { trashed: Mutex::new(Vec::new()), unread_calls: Mutex::new(Vec::new()), sent: Mutex::new(Vec::new()), + moved: Mutex::new(Vec::new()), } } @@ -1237,6 +1312,7 @@ mod tests { fn inbox_folder() -> FolderInfo { FolderInfo { id: "inbox".to_string(), + list_id: "folders".to_string(), entries_list_id: "inbox_entries".to_string(), kind: MailSetKind::Inbox, imap_path: "INBOX".to_string(), @@ -1244,6 +1320,54 @@ mod tests { } } + #[tokio::test] + async fn uid_move_moves_to_target_and_expunges() { + let m1 = make_mail("e1", "one", false); + let m2 = make_mail("e2", "two", false); + let backend = Arc::new(MockBackend::with_mails(vec![m1.clone(), m2.clone()])); + let store = MailStore::new(); + let target = FolderInfo { + id: "cust".to_string(), + list_id: "folders".to_string(), + entries_list_id: "cust_entries".to_string(), + kind: MailSetKind::Custom, + imap_path: "Work".to_string(), + special_use: None, + }; + store.set_folder_list(vec![inbox_folder(), target]).await; + store + .set_folder( + "inbox", + vec![ + StoredMail { mail: m1, details: None, rfc2822: None }, + StoredMail { mail: m2, details: None, rfc2822: None }, + ], + ) + .await; + let mut session = ImapSession::new(store, backend.clone(), None); + session.handle_command("a LOGIN u p").await; + session.handle_command("b SELECT INBOX").await; + + let resp = session.handle_command("c UID MOVE 1 Work").await; + assert!(resp.iter().any(|r| r.contains("EXPUNGE")), "expected EXPUNGE, got {resp:?}"); + assert!(resp.last().unwrap().contains("OK UID MOVE")); + + let moved = backend.moved.lock().unwrap(); + assert_eq!(moved.len(), 1); + assert_eq!(moved[0].1, "cust"); + assert_eq!(moved[0].0.len(), 1); + } + + #[tokio::test] + async fn copy_is_rejected() { + let backend = Arc::new(MockBackend::with_mails(vec![])); + let (_store, mut session) = make_session(backend).await; + session.handle_command("a LOGIN u p").await; + session.handle_command("b SELECT INBOX").await; + let resp = session.handle_command("c UID COPY 1 Work").await; + assert!(resp[0].contains("NO"), "COPY should be rejected, got {resp:?}"); + } + async fn populate_store(store: &MailStore, mails: &[Mail]) { store.set_folder_list(vec![inbox_folder()]).await; let stored: Vec = mails @@ -1285,6 +1409,10 @@ mod tests { self.trashed.lock().unwrap().extend(mail_ids); Ok(()) } + async fn move_mails(&self, mail_ids: Vec, target: &FolderInfo) -> Result<(), String> { + self.moved.lock().unwrap().push((mail_ids, target.id.clone())); + Ok(()) + } async fn send_mail(&self, msg: &ParsedMessage) -> Result<(), String> { self.sent.lock().unwrap().push(msg.clone()); Ok(()) diff --git a/crates/bridge/src/tuta.rs b/crates/bridge/src/tuta.rs index af95278..9544ccb 100644 --- a/crates/bridge/src/tuta.rs +++ b/crates/bridge/src/tuta.rs @@ -26,6 +26,9 @@ use crate::mail::ParsedMessage; pub struct FolderInfo { /// `MailSet` element id — the stable key used everywhere. pub id: String, + /// `MailSet` list id (the mailbox's folders list) — with `id` it forms the + /// folder's full `IdTuple`, needed as a move target. + pub list_id: String, /// `MailSet.entries` list id — used to load the mails in this folder. pub entries_list_id: String, pub kind: MailSetKind, @@ -46,6 +49,8 @@ pub trait MailBackend: Send + Sync { async fn list_folders(&self) -> Result, String>; async fn set_unread_status(&self, mail_ids: Vec, unread: bool) -> Result<(), String>; async fn trash_mails(&self, mail_ids: Vec) -> Result<(), String>; + /// Move mails into the given target folder. + async fn move_mails(&self, mail_ids: Vec, target: &FolderInfo) -> Result<(), String>; async fn send_mail(&self, msg: &ParsedMessage) -> Result<(), String>; } @@ -259,7 +264,11 @@ impl MailBackend for TutaSession { continue; // skip Scheduled / virtual sets } - let Some(elem_id) = folder._id.as_ref().map(|id| id.element_id.to_string()) else { + let Some((list_id, elem_id)) = folder + ._id + .as_ref() + .map(|id| (id.list_id.to_string(), id.element_id.to_string())) + else { continue; }; @@ -281,6 +290,7 @@ impl MailBackend for TutaSession { result.push(FolderInfo { id: elem_id, + list_id, entries_list_id: folder.entries.to_string(), kind, imap_path, @@ -310,6 +320,18 @@ impl MailBackend for TutaSession { .map_err(|e| format!("{e}")) } + async fn move_mails(&self, mail_ids: Vec, target: &FolderInfo) -> Result<(), String> { + let target_folder = IdTupleGenerated::new( + tutasdk::GeneratedId(target.list_id.clone()), + tutasdk::GeneratedId(target.id.clone()), + ); + self.logged_in + .mail_facade() + .move_mails(mail_ids, target_folder) + .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}")) } diff --git a/tuta-repo b/tuta-repo index cc429be..4f72f92 160000 --- a/tuta-repo +++ b/tuta-repo @@ -1 +1 @@ -Subproject commit cc429beaedec629b92142b3c8e5e9653b1449c8a +Subproject commit 4f72f927357bfd95a8886f6a4c8640bd57af5282