From f723e1eaa8457b8562aaefec9b631fa52ff3e8ee Mon Sep 17 00:00:00 2001 From: grabowski Date: Fri, 23 Oct 2026 10:24:14 +0700 Subject: [PATCH] sync: select destination read-write so PERMANENTFLAGS is real (flags were dropped) --- src/imap/client.rs | 12 +++++++++++- src/sync.rs | 4 ++-- tests/imap_fixture.py | 2 +- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/imap/client.rs b/src/imap/client.rs index 54a2eb4..86213ac 100644 --- a/src/imap/client.rs +++ b/src/imap/client.rs @@ -60,6 +60,9 @@ pub struct SelectInfo { pub exists: u32, pub uidvalidity: Option, pub permanent_flags: Option>, + /// EXAMINE / [READ-ONLY]: PERMANENTFLAGS is then empty and says nothing + /// about what APPEND will keep. + pub read_only: bool, } impl SelectInfo { @@ -67,6 +70,7 @@ impl SelectInfo { pub fn accepts_flag(&self, f: &str) -> bool { match &self.permanent_flags { None => true, + Some(_) if self.read_only => true, Some(p) => { p.iter().any(|x| x.eq_ignore_ascii_case(f)) || (!f.starts_with('\\') && p.iter().any(|x| x == "\\*")) } @@ -426,7 +430,10 @@ impl Client { let mut args = vec![Arg::Raw(format!("{verb} "))]; args.push(mailbox_arg(wire)); let r = self.run(args, &format!("{verb} {wire}")).await?; - let mut info = SelectInfo::default(); + let mut info = SelectInfo { + read_only: readonly || r.status.code.as_deref().is_some_and(|c| c.eq_ignore_ascii_case("READ-ONLY")), + ..Default::default() + }; for u in &r.untagged { if let Some(s) = u.status() { if let Some(code) = s.code { @@ -673,5 +680,8 @@ mod tests { assert!(!s.accepts_flag("\\Flagged")); let s = SelectInfo { permanent_flags: Some(vec!["\\Seen".into()]), ..Default::default() }; assert!(!s.accepts_flag("$Forwarded")); + // EXAMINE: PERMANENTFLAGS () is meaningless for APPEND + let s = SelectInfo { permanent_flags: Some(vec![]), read_only: true, ..Default::default() }; + assert!(s.accepts_flag("\\Seen")); } } diff --git a/src/sync.rs b/src/sync.rs index c68124c..b95ccfa 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -375,9 +375,9 @@ pub async fn sync_account( }); } let (dinfo, dst_meta) = if exists || !opts.dry_run { - let readonly = opts.dry_run || !opts.sync_flags; + // Read-write unless dry-run: EXAMINE reports empty PERMANENTFLAGS. let dinfo = d - .select(&fm.dst_wire, readonly) + .select(&fm.dst_wire, opts.dry_run) .await .with_context(|| format!("destination folder {}", fm.dst_name))?; let meta = if dinfo.exists > 0 { d.fetch_meta().await? } else { Vec::new() }; diff --git a/tests/imap_fixture.py b/tests/imap_fixture.py index 57bc4b9..e26b4a8 100644 --- a/tests/imap_fixture.py +++ b/tests/imap_fixture.py @@ -106,7 +106,7 @@ def inspect(user, password): if not isinstance(item, tuple): continue meta, hdr = item - flags = re.search(rb"FLAGS \((.*?)\)", meta).group(1).decode().split() + flags = [f for f in re.search(rb"FLAGS \((.*?)\)", meta).group(1).decode().split() if f != "\\Recent"] date = re.search(rb'INTERNALDATE "(.*?)"', meta).group(1).decode() size = int(re.search(rb"RFC822.SIZE (\d+)", meta).group(1)) mid = re.search(rb"Message-ID: <(.*?)>", hdr, re.I)