Skip to main content

sioul_sync/
mailbox.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! An account's folders, and what you do to its messages.
5//!
6//! Sioul writes to the server only when you act: a flag with `UID STORE`, a
7//! move with `UID MOVE` (RFC 6851), else a copy, a deletion flag and `UID
8//! EXPUNGE` (RFC 4315). The local copy follows: a flag renames its file; a
9//! moved message leaves this folder, and the next sync brings it into the other.
10//! Changes made elsewhere (read on the phone, deleted in the webmail) come back
11//! at each sync (`reconcile`).
12
13use crate::{SyncError, said};
14use crate::imap::{self, COMMAND, Imap, Server};
15use async_imap::types::NameAttribute;
16use futures_util::StreamExt;
17use sioul_core::card::ImapOrigin;
18use sioul_core::config::{Account, state_dir};
19use sioul_core::folders::{self, Folder, Role};
20use sioul_core::maildir;
21use sioul_core::spam::labels;
22use std::collections::{BTreeMap, BTreeSet};
23use std::path::{Path, PathBuf};
24
25/// The folders the server lists, with what each is for. Gmail's "All Mail",
26/// "Starred" and "Important" are views of the same messages: the first is kept
27/// to archive into, the other two are left out (RFC 6154, RFC 8457).
28pub async fn list(session: &mut Imap) -> Result<Vec<Folder>, SyncError> {
29    let mut found = Vec::new();
30    let mut names = imap::within(COMMAND, session.list(Some(""), Some("*"))).await?.map_err(imap::server)?;
31    while let Some(name) = names.next().await {
32        let name = name.map_err(imap::server)?;
33        let attributes = name.attributes();
34        if attributes.iter().any(|a| matches!(a, NameAttribute::NoSelect)) {
35            continue;
36        }
37        let special = attributes.iter().find_map(|a| match a {
38            NameAttribute::Sent => Some(Role::Sent),
39            NameAttribute::Drafts => Some(Role::Drafts),
40            NameAttribute::Junk => Some(Role::Junk),
41            NameAttribute::Trash => Some(Role::Trash),
42            NameAttribute::Archive => Some(Role::Archive),
43            NameAttribute::All => Some(Role::All),
44            NameAttribute::Flagged => Some(Role::Other),
45            _ => None,
46        });
47        let view = attributes.iter().any(|a| match a {
48            NameAttribute::Flagged => true,
49            NameAttribute::Extension(name) => name.eq_ignore_ascii_case("\\Important"),
50            _ => false,
51        });
52        if view {
53            continue;
54        }
55        found.push(folders::folder(name.name(), name.delimiter(), special));
56    }
57    drop(names);
58    found.sort_by(|a, b| (a.role, a.display.to_lowercase()).cmp(&(b.role, b.display.to_lowercase())));
59    Ok(found)
60}
61
62/// Whether a folder is fetched: everything but views of other folders, except
63/// where "All Mail" is the only archive there is (Gmail): it is fetched then,
64/// and shown as the archive, without what the inbox and Sent already show.
65/// Folders you chose not to keep stay on the server; the inbox is always kept.
66/// A folder whose name would make it the Maildir itself or the folder above
67/// ("INBOX." or "INBOX.." with "." between levels) is not fetched.
68pub(crate) fn fetched(folder: &Folder, all: &[Folder], account: &Account) -> bool {
69    (folder.role != Role::All || !all.iter().any(|f| f.role == Role::Archive))
70        && (folder.role == Role::Inbox || (!account.skip_folders.contains(&folder.name) && own_dir(account, folder).is_some()))
71}
72
73/// A folder's own directory in the account's Maildir: one name in it, never
74/// the Maildir itself nor what is above it, whatever the server named the folder.
75pub(crate) fn own_dir(account: &Account, folder: &Folder) -> Option<PathBuf> {
76    let mut parts = Path::new(&folder.local).components();
77    match (parts.next(), parts.next()) {
78        (Some(std::path::Component::Normal(_)), None) => Some(account.maildir_path().join(&folder.local)),
79        _ => None,
80    }
81}
82
83/// Makes a folder on the server (RFC 9051 §6.3.4), then lists them again.
84/// The name you typed goes in modified UTF-7, as servers read folder names
85/// ("Reçus" is "Re&AOc-us", RFC 3501 §5.1.3), the way they are listed back.
86pub fn create_folder(account: &Account, password: &str, name: &str) -> Result<(), SyncError> {
87    let name = name.trim();
88    if name.is_empty() {
89        return Err(SyncError::Said(said::FOLDER_NAME, String::new()));
90    }
91    let name = folders::encode_utf7(name);
92    let server = Server::of(account)?;
93    crate::fetch::block_on(async {
94        let mut session = imap::open(&server, password).await?;
95        let made = imap::within(COMMAND, session.create(&name)).await?.map_err(imap::server);
96        let listed = list(&mut session).await;
97        let _ = session.logout().await;
98        made?;
99        save(&account.id, &listed?)
100    })
101}
102
103/// The folder still holds messages: said by the window in your language.
104pub const NOT_EMPTY: &str = "not-empty";
105
106/// Takes a folder off the server (RFC 9051 §6.3.5): only an empty one, and
107/// never one with a purpose (the inbox, Sent, Drafts, the trash, junk, the
108/// archive), so no message is ever lost this way.
109pub fn delete_folder(account: &Account, password: &str, name: &str) -> Result<(), SyncError> {
110    let folder = folders(&account.id).into_iter().find(|f| f.name == name).ok_or_else(|| SyncError::Said(said::NO_FOLDER, name.to_string()))?;
111    if folder.role != Role::Other {
112        return Err(SyncError::Said(said::PURPOSE, folder.display.clone()));
113    }
114    let server = Server::of(account)?;
115    crate::fetch::block_on(async {
116        let mut session = imap::open(&server, password).await?;
117        let status = imap::within(COMMAND, session.status(name, "(MESSAGES)")).await?.map_err(imap::server)?;
118        if status.exists > 0 {
119            let _ = session.logout().await;
120            return Err(SyncError::Message(NOT_EMPTY.into()));
121        }
122        let deleted = imap::within(COMMAND, session.delete(name)).await?.map_err(imap::server);
123        let listed = list(&mut session).await;
124        let _ = session.logout().await;
125        deleted?;
126        save(&account.id, &listed?)
127    })?;
128    // Its empty copy here goes too, and where its sync stopped.
129    if let Some(dir) = own_dir(account, &folder) {
130        let _ = std::fs::remove_dir_all(dir);
131    }
132    crate::fetch::forget_folder_state(account, name)
133}
134
135/// A folder no longer kept here: its copy goes (the server keeps it all), and
136/// where its sync stopped is forgotten, so keeping it again brings it back whole.
137pub fn forget_folder(account: &Account, name: &str) -> Result<(), SyncError> {
138    if let Some(root) = folders(&account.id).into_iter().find(|f| f.name == name && f.role != Role::Inbox).and_then(|f| own_dir(account, &f)) {
139        for sub in ["cur", "new"] {
140            for entry in std::fs::read_dir(root.join(sub)).into_iter().flatten().filter_map(Result::ok) {
141                let _ = std::fs::remove_file(entry.path());
142            }
143        }
144    }
145    crate::fetch::forget_folder_state(account, name)
146}
147
148fn folders_path(account: &str) -> PathBuf {
149    folders::saved_path(account)
150}
151
152#[derive(serde::Serialize, serde::Deserialize, Default)]
153struct Saved {
154    #[serde(default, rename = "folder")]
155    folders: Vec<Folder>,
156}
157
158/// Keeps the list for the window and for actions, which need to know where the trash is.
159pub(crate) fn save(account: &str, list: &[Folder]) -> Result<(), SyncError> {
160    let path = folders_path(account);
161    let fail = |e: std::io::Error| SyncError::Disk(format!("{}: {e}", path.display()));
162    if let Some(parent) = path.parent() {
163        std::fs::create_dir_all(parent).map_err(fail)?;
164    }
165    let text = toml::to_string(&Saved { folders: list.to_vec() }).map_err(|e| SyncError::Disk(e.to_string()))?;
166    std::fs::write(&path, text).map_err(fail)
167}
168
169/// The account's folders as last listed: the inbox alone before the first sync.
170pub fn folders(account: &str) -> Vec<Folder> {
171    let saved = folders::saved(account);
172    if saved.is_empty() { vec![folders::folder("INBOX", None, None)] } else { saved }
173}
174
175/// The folder a stored message belongs to, from where its file is.
176pub fn folder_of(account: &Account, file: &Path) -> Option<Folder> {
177    let directory = file.parent()?.parent()?;
178    let root = account.maildir_path();
179    let all = folders(&account.id);
180    if let Some(folder) = all.iter().find(|f| root.join(&f.local) == directory) {
181        return Some(folder.clone());
182    }
183    // Each path as the system resolves it, where a folder sits behind a link (as `Config::account_of`).
184    let directory = std::fs::canonicalize(directory).ok()?;
185    all.into_iter().find(|f| std::fs::canonicalize(root.join(&f.local)).is_ok_and(|d| d == directory))
186}
187
188/// Brings back what changed on the server since the last sync, for the
189/// messages kept here: their flags, and those deleted or moved elsewhere,
190/// whose local copies go. The folder must be open (`EXAMINE` or `SELECT`).
191pub(crate) async fn reconcile(session: &mut Imap, root: &Path, validity: u32) -> Result<(), SyncError> {
192    let local = local_files(root);
193    let uids: BTreeSet<u32> = local.iter().filter(|(o, _)| o.validity == validity).map(|(o, _)| o.uid).collect();
194    if uids.is_empty() {
195        return Ok(());
196    }
197    let mut server: BTreeMap<u32, String> = BTreeMap::new();
198    {
199        let mut fetches = imap::within(COMMAND, session.uid_fetch(ranges(&uids), "(UID FLAGS)")).await?.map_err(imap::server)?;
200        while let Some(fetch) = fetches.next().await {
201            let fetch = fetch.map_err(imap::server)?;
202            if let Some(uid) = fetch.uid {
203                server.insert(uid, crate::fetch::maildir_flags(fetch.flags()));
204            }
205        }
206    }
207    for (origin, path) in local.into_iter().filter(|(o, _)| o.validity == validity) {
208        match server.get(&origin.uid) {
209            None => {
210                let _ = std::fs::remove_file(&path);
211            }
212            Some(flags) if sorted(flags) != sorted(&maildir::flags_of(&path)) => {
213                let _ = maildir::set_flags(&path, flags);
214            }
215            Some(_) => {}
216        }
217    }
218    Ok(())
219}
220
221fn sorted(flags: &str) -> String {
222    let mut letters: Vec<char> = flags.chars().collect();
223    letters.sort_unstable();
224    letters.dedup();
225    letters.into_iter().collect()
226}
227
228/// The stored messages of a Maildir folder, with where each sits on the server.
229fn local_files(root: &Path) -> Vec<(ImapOrigin, PathBuf)> {
230    ["new", "cur"]
231        .iter()
232        .filter_map(|sub| std::fs::read_dir(root.join(sub)).ok())
233        .flatten()
234        .filter_map(Result::ok)
235        .map(|e| e.path())
236        .filter_map(|p| Some((maildir::origin_of(&p)?, p)))
237        .collect()
238}
239
240/// "1:5,7,9:12": a UID set as short as it gets.
241pub(crate) fn ranges(uids: &BTreeSet<u32>) -> String {
242    let mut parts: Vec<String> = Vec::new();
243    let mut iter = uids.iter().copied().peekable();
244    while let Some(start) = iter.next() {
245        let mut end = start;
246        while iter.peek() == Some(&(end + 1)) {
247            end += 1;
248            iter.next();
249        }
250        parts.push(if start == end { start.to_string() } else { format!("{start}:{end}") });
251    }
252    parts.join(",")
253}
254
255/// What you do to a message.
256#[derive(Debug, Clone, PartialEq, Eq)]
257pub enum Action {
258    /// Read (true) or unread (false).
259    Read(bool),
260    /// Flagged to follow up, or not.
261    Flag(bool),
262    /// Into another folder, by its server name.
263    Move(String),
264    /// To the trash; from the trash, gone for good.
265    Trash,
266    /// To the junk folder, marked `$Junk` for the server's filter to learn.
267    Junk,
268    /// Out of the inbox, into the archive (Gmail: into "All Mail").
269    Archive,
270    /// Out of the junk folder, back into the inbox, marked `$NotJunk`.
271    NotJunk,
272    /// Not spam, said of mail set aside as spam or flagged by your own
273    /// filter: marked `$NotJunk` (and no longer `$Junk`), it stays where it
274    /// is, and the Porch puts it back in its lane.
275    NotSpam,
276    /// Into the account's Junk folder by your own spam filter (the matrix's
277    /// "Move to spam", for a message of this class): no keyword set, no line
278    /// in the label log, which is yours alone; the move goes into this
279    /// device's log of what the filter moved (`spam::labels::Moved`), which
280    /// the Porch's lane of its catches and the training read (in the Junk
281    /// folder, learned as spam until you say otherwise).
282    Filtered(sioul_core::spam::Class),
283}
284
285/// Does `action` to the message stored at `file`, on the server, then here.
286/// What it says of the message, junk or not, goes into this device's label
287/// log (`spam::labels`) once the server has taken it; what your own filter
288/// moved, into this device's log of its moves.
289pub fn act(account: &Account, password: &str, file: &Path, action: &Action) -> Result<(), SyncError> {
290    let server = Server::of(account)?;
291    let origin = maildir::origin_of(file).ok_or_else(|| SyncError::Said(said::NOT_FETCHED, String::new()))?;
292    let folder = folder_of(account, file).ok_or_else(|| SyncError::Said(said::UNKNOWN_FOLDER, String::new()))?;
293    if *action == Action::Archive && matches!(folder.role, Role::Archive | Role::All) {
294        return Ok(());
295    }
296    // Already in a Junk folder: nothing for the filter to move.
297    if matches!(action, Action::Filtered(_)) && folder.role == Role::Junk {
298        return Ok(());
299    }
300    // Read here before the message leaves this folder; written once the server took the act.
301    let label = said(action).and_then(|source| labels::Entry::of_file(&account.id, &folder.name, file, source));
302    let moved = match action {
303        Action::Filtered(class) => labels::Moved::of_file(&account.id, &folder.name, file, *class),
304        _ => None,
305    };
306    crate::fetch::block_on(async {
307        let mut session = imap::open(&server, password).await?;
308        let result = act_in(&mut session, account, &folder, origin, action).await;
309        let _ = session.logout().await;
310        result
311    })?;
312    // Done on the server: a log that cannot be written is said, after the rest is done here.
313    let root = labels::state();
314    let logged = match (&label, &moved) {
315        (Some(label), _) => labels::append_to(&labels::own_log(&root, &device()), label),
316        (_, Some(moved)) => labels::append_to(&labels::own_moved_log(&root, &device()), moved),
317        _ => Ok(()),
318    }
319    .map_err(SyncError::Disk);
320    // A sync may have renamed the file meanwhile; gone, there is nothing left to do here.
321    let Some(file) = maildir::locate(file) else { return logged };
322    match kept_with(action, &maildir::flags_of(&file), folder.role) {
323        Some(flags) => {
324            maildir::set_flags(&file, &flags).map_err(|e| SyncError::Disk(e.to_string()))?;
325        }
326        None => {
327            let _ = std::fs::remove_file(&file);
328        }
329    }
330    logged
331}
332
333/// What becomes of the local copy once the server took `action` on it in a
334/// folder of `role`: its letters now, or none when it left this folder (the
335/// next sync brings the copy into the other). Said not spam, it keeps its
336/// keyword here as on the server; said spam in a Junk folder, it stays there.
337fn kept_with(action: &Action, flags: &str, role: Role) -> Option<String> {
338    match action {
339        Action::Read(read) => Some(if *read { format!("{flags}S") } else { flags.replace('S', "") }),
340        Action::Flag(flag) => Some(if *flag { format!("{flags}F") } else { flags.replace('F', "") }),
341        Action::NotSpam => Some(format!("{}{}", flags.replace(maildir::JUNK, ""), maildir::NOT_JUNK)),
342        Action::Junk if role == Role::Junk => Some(format!("{}{}", flags.replace(maildir::NOT_JUNK, "").replace(maildir::JUNK, ""), maildir::JUNK)),
343        Action::Move(_) | Action::Trash | Action::Junk | Action::Archive | Action::NotJunk | Action::Filtered(_) => None,
344    }
345}
346
347/// This device's name in the sharing (a UUID made once, `share::Here`): its
348/// label log and its logs of what the filter moved or flagged are its own files.
349fn device() -> String {
350    crate::share::Here::load(&state_dir()).id
351}
352
353/// Writes into the spam filter's label log what you said of the message
354/// stored at `file`, without acting on it there: a sender blocked from it.
355pub fn label(account: &Account, file: &Path, source: labels::Source) -> Result<(), SyncError> {
356    let folder = folder_of(account, file).ok_or_else(|| SyncError::Said(said::UNKNOWN_FOLDER, String::new()))?;
357    let entry = labels::Entry::of_file(&account.id, &folder.name, file, source).ok_or_else(|| SyncError::Said(said::NOT_FETCHED, String::new()))?;
358    labels::append_to(&labels::own_log(&labels::state(), &device()), &entry).map_err(SyncError::Disk)
359}
360
361/// Writes into this device's log of what your own filter flagged where it
362/// is (`spam::labels::Flagged`) the message stored at `file`, judged
363/// `class`, as it arrived: nothing changes on the server nor here. The
364/// training learns from it as it is until you say otherwise: a probable spam
365/// as spam, a maybe spam not at all.
366pub fn flagged(account: &Account, file: &Path, class: sioul_core::spam::Class) -> Result<(), SyncError> {
367    let folder = folder_of(account, file).ok_or_else(|| SyncError::Said(said::UNKNOWN_FOLDER, String::new()))?;
368    let flag = labels::Flagged::of_file(&account.id, &folder.name, file, class).ok_or_else(|| SyncError::Said(said::NOT_FETCHED, String::new()))?;
369    labels::append_to(&labels::own_flagged_log(&labels::state(), &device()), &flag).map_err(SyncError::Disk)
370}
371
372/// What an act says of a message, for the label log: none for those that
373/// say nothing of spam, nor for your own filter's move (its own log's).
374fn said(action: &Action) -> Option<labels::Source> {
375    match action {
376        Action::Junk => Some(labels::Source::Junk),
377        Action::NotJunk => Some(labels::Source::NotJunk),
378        Action::NotSpam => Some(labels::Source::NotSpam),
379        Action::Read(_) | Action::Flag(_) | Action::Move(_) | Action::Trash | Action::Archive | Action::Filtered(_) => None,
380    }
381}
382
383/// Moves the message stored at `file` into `folder` of another account: a
384/// copy is appended there, its flags kept, and only once that server has it
385/// is the original taken off the first one. Nothing is lost on the way: if the
386/// copy fails, the original stays; if removing the original fails, both remain.
387pub fn move_across(from: &Account, from_password: &str, file: &Path, to: &Account, to_password: &str, folder: &str) -> Result<(), SyncError> {
388    let raw = std::fs::read(file).map_err(|e| SyncError::Disk(format!("{}: {e}", file.display())))?;
389    let origin = maildir::origin_of(file).ok_or_else(|| SyncError::Said(said::NOT_FETCHED, String::new()))?;
390    let source = folder_of(from, file).ok_or_else(|| SyncError::Said(said::UNKNOWN_FOLDER, String::new()))?;
391    append_then_remove(from, from_password, &source, origin, &raw, &maildir::flags_of(file), to, to_password, folder)?;
392    if let Some(file) = maildir::locate(file) {
393        let _ = std::fs::remove_file(&file);
394    }
395    Ok(())
396}
397
398/// The same for a message seen on its server only (a search's): read whole
399/// from its folder first, its flags with it, then copied and taken off.
400pub fn move_across_from_server(from: &Account, from_password: &str, source: &str, origin: ImapOrigin, to: &Account, to_password: &str, folder: &str) -> Result<(), SyncError> {
401    let source = folders(&from.id).into_iter().find(|f| f.name == source).unwrap_or_else(|| folders::folder(source, None, None));
402    let server = Server::of(from)?;
403    let (raw, flags) = crate::fetch::block_on(async {
404        let mut session = imap::open(&server, from_password).await?;
405        let result = read_whole(&mut session, &source, origin).await;
406        let _ = session.logout().await;
407        result
408    })?;
409    append_then_remove(from, from_password, &source, origin, &raw, &flags, to, to_password, folder)
410}
411
412/// A message of `folder`, whole, with its flags as Maildir letters.
413async fn read_whole(session: &mut Imap, folder: &Folder, origin: ImapOrigin) -> Result<(Vec<u8>, String), SyncError> {
414    let examined = imap::within(COMMAND, session.examine(&folder.name)).await?.map_err(imap::server)?;
415    if examined.uid_validity.unwrap_or(0) != origin.validity {
416        return Err(SyncError::Said(said::RENUMBERED, String::new()));
417    }
418    let mut fetches = imap::within(COMMAND, session.uid_fetch(origin.uid.to_string(), "(UID FLAGS BODY.PEEK[])")).await?.map_err(imap::server)?;
419    let mut found = None;
420    while let Some(fetch) = fetches.next().await {
421        let fetch = fetch.map_err(imap::server)?;
422        if fetch.uid == Some(origin.uid)
423            && let Some(body) = fetch.body()
424        {
425            found = Some((body.to_vec(), crate::fetch::maildir_flags(fetch.flags())));
426        }
427    }
428    found.ok_or_else(|| SyncError::Said(said::GONE, String::new()))
429}
430
431/// Copies `raw` into `folder` of `to`, its flags kept (Maildir letters), and
432/// only once that server has it takes the original off `source` of `from`.
433#[allow(clippy::too_many_arguments)]
434fn append_then_remove(from: &Account, from_password: &str, source: &Folder, origin: ImapOrigin, raw: &[u8], letters: &str, to: &Account, to_password: &str, folder: &str) -> Result<(), SyncError> {
435    let flags: Vec<&str> = [('S', "\\Seen"), ('F', "\\Flagged"), ('R', "\\Answered")].into_iter().filter(|(c, _)| letters.contains(*c)).map(|(_, f)| f).collect();
436    let flags = format!("({})", flags.join(" "));
437    let target = Server::of(to)?;
438    crate::fetch::block_on(async {
439        let mut session = imap::open(&target, to_password).await?;
440        let result = imap::within(COMMAND, session.append(folder, Some(&flags), None, raw)).await?.map_err(imap::server);
441        let _ = session.logout().await;
442        result
443    })?;
444    let server = Server::of(from)?;
445    crate::fetch::block_on(async {
446        let mut session = imap::open(&server, from_password).await?;
447        let result = remove_in(&mut session, source, origin).await;
448        let _ = session.logout().await;
449        result
450    })
451}
452
453/// UIDs per command, at most: a long set stays well under the line servers take.
454const SET: usize = 200;
455
456/// The same act on several messages of one account, in one connection: a
457/// UID set per folder (RFC 9051 §6.4.9). `files` are kept here; `remote` are
458/// seen on the server only (a search's), by their folder's server name. For
459/// acts that say nothing of spam (reading, flags, moves, archiving, the
460/// trash); the others go one at a time through `act`, for the spam filter's
461/// logs. The local copies follow as with `act`. A folder that fails is said
462/// (the first problem), the others are still done.
463pub fn act_many(account: &Account, password: &str, files: &[PathBuf], remote: &[(String, ImapOrigin)], action: &Action) -> Result<(), SyncError> {
464    if said(action).is_some() || matches!(action, Action::Filtered(_)) {
465        let mut first = None;
466        for file in files {
467            if let Err(e) = act(account, password, file, action) {
468                first.get_or_insert(e);
469            }
470        }
471        if !remote.is_empty() {
472            first.get_or_insert(SyncError::Said(said::OPEN_FIRST, String::new()));
473        }
474        return first.map_or(Ok(()), Err);
475    }
476    let known = folders(&account.id);
477    // By folder, then UIDVALIDITY: the UIDs, and the files that follow.
478    let mut groups: BTreeMap<String, (Folder, BTreeMap<u32, (BTreeSet<u32>, Vec<PathBuf>)>)> = BTreeMap::new();
479    let mut add = |folder: Folder, origin: ImapOrigin, file: Option<&PathBuf>| {
480        let entry = groups.entry(folder.name.clone()).or_insert_with(|| (folder, BTreeMap::new()));
481        let (uids, files) = entry.1.entry(origin.validity).or_default();
482        uids.insert(origin.uid);
483        files.extend(file.cloned());
484    };
485    let mut first: Option<SyncError> = None;
486    for file in files {
487        match (maildir::origin_of(file), folder_of(account, file)) {
488            (Some(origin), Some(folder)) => add(folder, origin, Some(file)),
489            _ => {
490                first.get_or_insert(SyncError::Said(said::NOT_FETCHED, String::new()));
491            }
492        }
493    }
494    for (name, origin) in remote {
495        let folder = known.iter().find(|f| f.name == *name).cloned().unwrap_or_else(|| folders::folder(name, None, None));
496        add(folder, *origin, None);
497    }
498    // Archived already: nothing to do (as `act`).
499    groups.retain(|_, (folder, _)| !(*action == Action::Archive && matches!(folder.role, Role::Archive | Role::All)));
500    if groups.is_empty() {
501        return first.map_or(Ok(()), Err);
502    }
503    let server = Server::of(account)?;
504    let done: Vec<(Role, Vec<PathBuf>)> = crate::fetch::block_on(async {
505        let mut session = imap::open(&server, password).await?;
506        let mut done = Vec::new();
507        for (folder, by_validity) in groups.values() {
508            for (validity, (uids, files)) in by_validity {
509                let uids: Vec<u32> = uids.iter().copied().collect();
510                let mut all_done = true;
511                for chunk in uids.chunks(SET) {
512                    let set = ranges(&chunk.iter().copied().collect::<BTreeSet<u32>>());
513                    if let Err(e) = act_set(&mut session, account, folder, *validity, &set, action).await {
514                        all_done = false;
515                        first.get_or_insert(e);
516                        break;
517                    }
518                }
519                if all_done {
520                    done.push((folder.role, files.clone()));
521                }
522            }
523        }
524        let _ = session.logout().await;
525        Ok(done)
526    })?;
527    // Done on the server: the copies here follow.
528    for (role, files) in done {
529        for file in files.iter().filter_map(|f| maildir::locate(f)) {
530            match kept_with(action, &maildir::flags_of(&file), role) {
531                Some(flags) => {
532                    let _ = maildir::set_flags(&file, &flags);
533                }
534                None => {
535                    let _ = std::fs::remove_file(&file);
536                }
537            }
538        }
539    }
540    first.map_or(Ok(()), Err)
541}
542
543/// Takes a message off its folder for good: marked deleted, then expunged.
544async fn remove_in(session: &mut Imap, folder: &Folder, origin: ImapOrigin) -> Result<(), SyncError> {
545    let selected = imap::within(COMMAND, session.select(&folder.name)).await?.map_err(imap::server)?;
546    if selected.uid_validity.unwrap_or(0) != origin.validity {
547        return Err(SyncError::Said(said::RENUMBERED, String::new()));
548    }
549    let uid = origin.uid.to_string();
550    let capabilities = imap::within(COMMAND, session.capabilities()).await?.map_err(imap::server)?;
551    store(session, &uid, "+FLAGS.SILENT (\\Deleted)").await?;
552    expunge(session, &uid, capabilities.has_str("UIDPLUS")).await
553}
554
555async fn act_in(session: &mut Imap, account: &Account, folder: &Folder, origin: ImapOrigin, action: &Action) -> Result<(), SyncError> {
556    act_set(session, account, folder, origin.validity, &origin.uid.to_string(), action).await
557}
558
559/// `action` on the messages of `uid`, a UID or a set of them ("4,7:9"), in
560/// `folder`, whose UIDVALIDITY must still be `validity`.
561async fn act_set(session: &mut Imap, account: &Account, folder: &Folder, validity: u32, uid: &str, action: &Action) -> Result<(), SyncError> {
562    let selected = imap::within(COMMAND, session.select(&folder.name)).await?.map_err(imap::server)?;
563    if selected.uid_validity.unwrap_or(0) != validity {
564        return Err(SyncError::Said(said::RENUMBERED, String::new()));
565    }
566    let capabilities = imap::within(COMMAND, session.capabilities()).await?.map_err(imap::server)?;
567    let (can_move, uidplus) = (capabilities.has_str("MOVE"), capabilities.has_str("UIDPLUS"));
568    match action {
569        Action::Read(read) => store(session, uid, if *read { "+FLAGS.SILENT (\\Seen)" } else { "-FLAGS.SILENT (\\Seen)" }).await,
570        Action::Flag(flag) => store(session, uid, if *flag { "+FLAGS.SILENT (\\Flagged)" } else { "-FLAGS.SILENT (\\Flagged)" }).await,
571        Action::Move(target) => move_to(session, uid, target, can_move, uidplus).await,
572        Action::Trash if folder.role == Role::Trash => {
573            store(session, uid, "+FLAGS.SILENT (\\Deleted)").await?;
574            expunge(session, uid, uidplus).await
575        }
576        Action::Trash => {
577            let target = role_folder(session, account, Role::Trash, "Trash").await?;
578            move_to(session, uid, &target, can_move, uidplus).await
579        }
580        Action::Junk => {
581            // Keywords are optional: a server without them still takes the move.
582            let _ = store(session, uid, "-FLAGS.SILENT ($NotJunk)").await;
583            let _ = store(session, uid, "+FLAGS.SILENT ($Junk)").await;
584            // Said spam in a Junk folder already (the review queue's "Spam" on what your filter moved): it stays.
585            if folder.role == Role::Junk {
586                return Ok(());
587            }
588            let target = role_folder(session, account, Role::Junk, "Junk").await?;
589            move_to(session, uid, &target, can_move, uidplus).await
590        }
591        // Your own filter's move: no keyword, as nobody said anything yet.
592        Action::Filtered(_) => {
593            let target = role_folder(session, account, Role::Junk, "Junk").await?;
594            move_to(session, uid, &target, can_move, uidplus).await
595        }
596        Action::Archive => {
597            let known = folders(&account.id);
598            let target = match folders::preferred(&known, Role::Archive).or(folders::preferred(&known, Role::All)) {
599                Some(f) => f.name.clone(),
600                None => role_folder(session, account, Role::Archive, "Archive").await?,
601            };
602            move_to(session, uid, &target, can_move, uidplus).await
603        }
604        Action::NotJunk => {
605            let _ = store(session, uid, "-FLAGS.SILENT ($Junk)").await;
606            let _ = store(session, uid, "+FLAGS.SILENT ($NotJunk)").await;
607            move_to(session, uid, "INBOX", can_move, uidplus).await
608        }
609        // A server without keywords keeps nothing: the label log then says it, on this device.
610        Action::NotSpam => {
611            let _ = store(session, uid, "-FLAGS.SILENT ($Junk)").await;
612            let _ = store(session, uid, "+FLAGS.SILENT ($NotJunk)").await;
613            Ok(())
614        }
615    }
616}
617
618pub(crate) async fn store(session: &mut Imap, uid: &str, query: &str) -> Result<(), SyncError> {
619    let mut answers = imap::within(COMMAND, session.uid_store(uid, query)).await?.map_err(imap::server)?;
620    while let Some(answer) = answers.next().await {
621        answer.map_err(imap::server)?;
622    }
623    Ok(())
624}
625
626pub(crate) async fn move_to(session: &mut Imap, uid: &str, target: &str, can_move: bool, uidplus: bool) -> Result<(), SyncError> {
627    if can_move {
628        return imap::within(COMMAND, session.uid_mv(uid, target)).await?.map_err(imap::server);
629    }
630    imap::within(COMMAND, session.uid_copy(uid, target)).await?.map_err(imap::server)?;
631    store(session, uid, "+FLAGS.SILENT (\\Deleted)").await?;
632    expunge(session, uid, uidplus).await
633}
634
635/// Removes a message marked deleted. Without UIDPLUS, a plain EXPUNGE could also
636/// remove other messages marked deleted by another client: the mark is left,
637/// and most clients hide such messages.
638async fn expunge(session: &mut Imap, uid: &str, uidplus: bool) -> Result<(), SyncError> {
639    if !uidplus {
640        return Ok(());
641    }
642    let gone = imap::within(COMMAND, session.uid_expunge(uid)).await?.map_err(imap::server)?;
643    let mut gone = std::pin::pin!(gone);
644    while let Some(answer) = gone.next().await {
645        answer.map_err(imap::server)?;
646    }
647    Ok(())
648}
649
650/// The server name of the folder for `role`; made, with `name`, when the server has none.
651pub(crate) async fn role_folder(session: &mut Imap, account: &Account, role: Role, name: &str) -> Result<String, SyncError> {
652    if let Some(folder) = folders::preferred(&folders(&account.id), role) {
653        return Ok(folder.name.clone());
654    }
655    let listed = list(session).await?;
656    if let Some(folder) = folders::preferred(&listed, role) {
657        let name = folder.name.clone();
658        save(&account.id, &listed)?;
659        return Ok(name);
660    }
661    // Under the inbox where the server keeps its folders there ("INBOX.Trash").
662    let under_inbox = listed.iter().any(|f| f.name.to_ascii_uppercase().starts_with("INBOX."));
663    let name = if under_inbox { format!("INBOX.{name}") } else { name.to_string() };
664    imap::within(COMMAND, session.create(&name)).await?.map_err(imap::server)?;
665    let listed = list(session).await?;
666    save(&account.id, &listed)?;
667    Ok(name)
668}
669
670#[cfg(test)]
671mod tests {
672    use super::*;
673
674    #[test]
675    fn what_an_act_leaves_here_and_says() {
676        // Said not spam: it stays, its `$Junk` letter traded for `$NotJunk`'s.
677        assert_eq!(kept_with(&Action::NotSpam, &format!("S{}", maildir::JUNK), Role::Inbox), Some(format!("S{}", maildir::NOT_JUNK)));
678        assert_eq!(kept_with(&Action::NotSpam, "", Role::Inbox), Some(maildir::NOT_JUNK.to_string()));
679        // Read, flagged: the keywords stay beside the flags.
680        assert_eq!(kept_with(&Action::Read(false), &format!("S{}", maildir::NOT_JUNK), Role::Inbox), Some(maildir::NOT_JUNK.to_string()));
681        // Moved: it leaves this folder; your filter's move too.
682        assert_eq!(kept_with(&Action::Junk, "S", Role::Inbox), None);
683        assert_eq!(kept_with(&Action::NotJunk, "S", Role::Junk), None);
684        assert_eq!(kept_with(&Action::Filtered(sioul_core::spam::Class::Spam), "", Role::Inbox), None);
685        // Said spam where your filter moved it: it stays, `$Junk` now.
686        assert_eq!(kept_with(&Action::Junk, &format!("S{}", maildir::NOT_JUNK), Role::Junk), Some(format!("S{}", maildir::JUNK)));
687        // What goes into the label log, and what does not: your filter's move is never a label.
688        assert_eq!([&Action::Junk, &Action::NotJunk, &Action::NotSpam].map(said), [Some(labels::Source::Junk), Some(labels::Source::NotJunk), Some(labels::Source::NotSpam)]);
689        assert_eq!([&Action::Read(true), &Action::Trash, &Action::Archive, &Action::Filtered(sioul_core::spam::Class::Spam)].map(said), [None, None, None, None]);
690    }
691
692    #[test]
693    fn uid_sets_are_short() {
694        let uids: BTreeSet<u32> = [1, 2, 3, 4, 5, 7, 9, 10, 11, 12].into_iter().collect();
695        assert_eq!(ranges(&uids), "1:5,7,9:12");
696        assert_eq!(ranges(&[42].into_iter().collect()), "42");
697    }
698}