Skip to main content

sioul_core/
folders.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Mail folders: what each is for, its name as you read it, and where it is
5//! kept on disk.
6//!
7//! Servers say what a folder is for with special-use attributes (RFC 6154:
8//! `\Sent`, `\Drafts`, `\Junk`, `\Trash`, `\Archive`, `\All`); when they do
9//! not, the usual names tell it. Names travel in modified UTF-7 (RFC 3501
10//! §5.1.3). Each folder is a Maildir++ subfolder of the account's Maildir, the
11//! inbox being the Maildir itself, as Dovecot lays them out: "INBOX.Sent" and
12//! "Sent" both become `.Sent`.
13
14use serde::{Deserialize, Serialize};
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
17#[serde(rename_all = "lowercase")]
18pub enum Role {
19    Inbox,
20    Drafts,
21    Sent,
22    Archive,
23    Junk,
24    Trash,
25    /// Every message at once (Gmail's "All Mail"): where archiving moves mail there.
26    All,
27    Other,
28}
29
30impl Role {
31    /// The message naming it ("folder-sent").
32    pub fn message_id(self) -> &'static str {
33        match self {
34            Role::Inbox => "folder-inbox",
35            Role::Drafts => "folder-drafts",
36            Role::Sent => "folder-sent",
37            Role::Archive => "folder-archive",
38            Role::Junk => "folder-junk",
39            Role::Trash => "folder-trash",
40            Role::All => "folder-all",
41            Role::Other => "folder-other",
42        }
43    }
44
45    /// Its icon, from the desktop's theme.
46    pub fn icon(self) -> &'static str {
47        match self {
48            Role::Inbox => "mail-folder-inbox",
49            Role::Drafts => "document-edit",
50            Role::Sent => "mail-folder-sent",
51            Role::Archive => "archive-insert",
52            Role::Junk => "mail-mark-junk",
53            Role::Trash => "user-trash",
54            Role::All => "folder-mail",
55            Role::Other => "folder",
56        }
57    }
58}
59
60/// One folder of an account.
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62pub struct Folder {
63    /// The name the server uses, for commands.
64    pub name: String,
65    /// The name as you read it.
66    pub display: String,
67    pub role: Role,
68    /// Its Maildir++ directory under the account's Maildir: "" for the inbox, ".Sent".
69    pub local: String,
70    /// The server said what it is for (RFC 6154), rather than its name.
71    #[serde(default)]
72    pub special: bool,
73}
74
75/// Where an account's folders, as its server last listed them, are kept:
76/// `$XDG_STATE_HOME/sioul/sync/<account>.folders.toml` (sioul-sync writes it at each sync).
77pub fn saved_path(account: &str) -> std::path::PathBuf {
78    crate::config::state_dir().join("sync").join(format!("{account}.folders.toml"))
79}
80
81/// An account's folders as its server last listed them; none before its first sync.
82pub fn saved(account: &str) -> Vec<Folder> {
83    #[derive(Deserialize)]
84    struct Saved {
85        #[serde(default, rename = "folder")]
86        folders: Vec<Folder>,
87    }
88    std::fs::read_to_string(saved_path(account)).ok().and_then(|t| toml::from_str::<Saved>(&t).ok()).map(|s| s.folders).unwrap_or_default()
89}
90
91/// The folder for a role when several have it ("Junk" and "spam" on cPanel
92/// hosts): the one the server marks, else the first.
93pub fn preferred(folders: &[Folder], role: Role) -> Option<&Folder> {
94    folders.iter().find(|f| f.role == role && f.special).or_else(|| folders.iter().find(|f| f.role == role))
95}
96
97/// What a folder is for: the server's word first, else its name, as the
98/// words in use name folders (`words::current`: the configuration's).
99pub fn role_of(name: &str, special: Option<Role>) -> Role {
100    role_in(&crate::words::current().folders, name, special)
101}
102
103/// What a folder is for: the server's word first, else its name among the
104/// names providers give their folders when they do not say what for
105/// (`words::FolderWords`), capitals and accents aside.
106pub fn role_in(names: &crate::words::FolderWords, name: &str, special: Option<Role>) -> Role {
107    if name.eq_ignore_ascii_case("INBOX") {
108        return Role::Inbox;
109    }
110    if let Some(role) = special {
111        return role;
112    }
113    let display = decode_utf7(name);
114    // The last level of the name: "INBOX.Sent", "[Gmail]/Sent Mail".
115    let leaf = crate::words::folded(display.rsplit(['.', '/']).next().unwrap_or(&display));
116    let named = |list: &[String]| list.iter().any(|n| crate::words::folded(n) == leaf);
117    [(Role::Sent, &names.sent), (Role::Drafts, &names.drafts), (Role::Junk, &names.junk), (Role::Trash, &names.trash), (Role::Archive, &names.archive)]
118        .into_iter()
119        .find(|(_, list)| named(list))
120        .map_or(Role::Other, |(role, _)| role)
121}
122
123/// A folder from what the server lists.
124pub fn folder(name: &str, delimiter: Option<&str>, special: Option<Role>) -> Folder {
125    let role = role_of(name, special);
126    let display = display_name(name, delimiter);
127    let local = if role == Role::Inbox { String::new() } else { local_dir(&display, delimiter) };
128    Folder { name: name.to_string(), display, role, local, special: special.is_some_and(|s| s == role) }
129}
130
131/// `INBOX.Sent` → `Sent`, `[Gmail]/Sent Mail` → `[Gmail]/Sent Mail`, `Re&AOc-us` → `Reçus`.
132fn display_name(name: &str, delimiter: Option<&str>) -> String {
133    let decoded = decode_utf7(name);
134    match delimiter {
135        Some(d) if !d.is_empty() => {
136            let prefix = format!("INBOX{d}");
137            match decoded.get(..prefix.len()) {
138                Some(head) if head.eq_ignore_ascii_case(&prefix) => decoded[prefix.len()..].to_string(),
139                _ => decoded,
140            }
141        }
142        _ => decoded,
143    }
144}
145
146/// The Maildir++ directory: a dot, then the levels joined by dots. Characters
147/// that a level's name cannot hold on disk, on any system, are written `%XX`.
148/// An empty level ("INBOX.", "a//b", "/" where "/" separates levels) is
149/// written `%00`: joined as it is, it could make "." or "..", the account's
150/// Maildir or the folder of every account's, which removing this folder's
151/// copy would delete.
152fn local_dir(display: &str, delimiter: Option<&str>) -> String {
153    let levels: Vec<&str> = match delimiter {
154        Some(d) if !d.is_empty() => display.split(d).collect(),
155        _ => vec![display],
156    };
157    let encoded: Vec<String> = levels
158        .iter()
159        .map(|level| {
160            if level.is_empty() {
161                return "%00".to_string();
162            }
163            level
164                .chars()
165                .map(|c| match c {
166                    '.' | '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' | '%' => format!("%{:02X}", c as u32),
167                    c if c.is_control() => format!("%{:02X}", c as u32),
168                    c => c.to_string(),
169                })
170                .collect()
171        })
172        .collect();
173    format!(".{}", encoded.join("."))
174}
175
176/// Modified UTF-7 (RFC 3501 §5.1.3): `&` opens base64 of UTF-16BE, with `,` for
177/// `/`, until `-`; `&-` is `&`. Anything malformed is kept as it came.
178pub fn decode_utf7(name: &str) -> String {
179    let mut out = String::with_capacity(name.len());
180    let mut rest = name;
181    while let Some(start) = rest.find('&') {
182        out.push_str(&rest[..start]);
183        let after = &rest[start + 1..];
184        let Some(end) = after.find('-') else {
185            out.push_str(&rest[start..]);
186            return out;
187        };
188        let encoded = &after[..end];
189        if encoded.is_empty() {
190            out.push('&');
191        } else {
192            match decode_base64_utf16(encoded) {
193                Some(text) => out.push_str(&text),
194                None => out.push_str(&rest[start..start + 2 + end]),
195            }
196        }
197        rest = &after[end + 1..];
198    }
199    out.push_str(rest);
200    out
201}
202
203/// Encodes a name for the server, the reverse of `decode_utf7`.
204pub fn encode_utf7(name: &str) -> String {
205    let mut out = String::new();
206    let mut pending: Vec<u16> = Vec::new();
207    let flush = |pending: &mut Vec<u16>, out: &mut String| {
208        if pending.is_empty() {
209            return;
210        }
211        let bytes: Vec<u8> = pending.iter().flat_map(|u| u.to_be_bytes()).collect();
212        out.push('&');
213        out.push_str(&encode_base64(&bytes).replace('/', ","));
214        out.push('-');
215        pending.clear();
216    };
217    for c in name.chars() {
218        if (' '..='~').contains(&c) {
219            flush(&mut pending, &mut out);
220            if c == '&' { out.push_str("&-") } else { out.push(c) }
221        } else {
222            let mut units = [0u16; 2];
223            pending.extend_from_slice(c.encode_utf16(&mut units));
224        }
225    }
226    flush(&mut pending, &mut out);
227    out
228}
229
230const BASE64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
231
232fn decode_base64_utf16(encoded: &str) -> Option<String> {
233    let mut bits = 0u32;
234    let mut count = 0;
235    let mut bytes = Vec::new();
236    for c in encoded.bytes() {
237        let c = if c == b',' { b'/' } else { c };
238        let value = BASE64.iter().position(|b| *b == c)? as u32;
239        bits = (bits << 6) | value;
240        count += 6;
241        if count >= 8 {
242            count -= 8;
243            bytes.push((bits >> count) as u8);
244            bits &= (1 << count) - 1;
245        }
246    }
247    let units: Vec<u16> = bytes.chunks_exact(2).map(|p| u16::from_be_bytes([p[0], p[1]])).collect();
248    String::from_utf16(&units).ok()
249}
250
251fn encode_base64(bytes: &[u8]) -> String {
252    let mut out = String::new();
253    for chunk in bytes.chunks(3) {
254        let n = chunk.iter().fold(0u32, |n, b| (n << 8) | u32::from(*b)) << (8 * (3 - chunk.len()));
255        let chars = chunk.len() + 1;
256        for i in 0..chars {
257            out.push(BASE64[((n >> (18 - 6 * i)) & 63) as usize] as char);
258        }
259    }
260    out
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266
267    #[test]
268    fn the_marked_folder_wins_its_role() {
269        let listed = vec![folder("Junk", Some("."), Some(Role::Junk)), folder("spam", Some("."), None), folder("Archives", Some("."), None)];
270        assert_eq!(listed[1].role, Role::Junk);
271        assert!(listed[0].special && !listed[1].special);
272        assert_eq!(preferred(&listed, Role::Junk).map(|f| f.name.as_str()), Some("Junk"));
273        assert_eq!(preferred(&listed, Role::Archive).map(|f| f.name.as_str()), Some("Archives"));
274        assert_eq!(preferred(&listed, Role::Trash), None);
275    }
276
277    #[test]
278    fn names_in_modified_utf7() {
279        assert_eq!(decode_utf7("Re&AOc-us"), "Reçus");
280        assert_eq!(decode_utf7("&AMk-l&AOk-ments envoy&AOk-s"), "Éléments envoyés");
281        assert_eq!(decode_utf7("Tom &- Jerry"), "Tom & Jerry");
282        assert_eq!(decode_utf7("&ZeVnLIqe-"), "日本語");
283        for name in ["Reçus", "Éléments envoyés", "Tom & Jerry", "日本語", "Plain"] {
284            assert_eq!(decode_utf7(&encode_utf7(name)), name);
285        }
286        assert_eq!(encode_utf7("Reçus"), "Re&AOc-us");
287    }
288
289    #[test]
290    fn roles_and_places() {
291        let sent = folder("INBOX.Sent", Some("."), None);
292        assert_eq!((sent.role, sent.display.as_str(), sent.local.as_str()), (Role::Sent, "Sent", ".Sent"));
293        let gmail = folder("[Gmail]/Sent Mail", Some("/"), Some(Role::Sent));
294        assert_eq!((gmail.role, gmail.local.as_str()), (Role::Sent, ".[Gmail].Sent Mail"));
295        assert_eq!(folder("INBOX", Some("."), None).local, "");
296        assert_eq!(folder("Corbeille", Some("/"), None).role, Role::Trash);
297        assert_eq!(folder("INBOX.spam", Some("."), None).role, Role::Junk);
298        assert_eq!(folder("Projets/2026", Some("/"), None).local, ".Projets.2026");
299        assert_eq!(folder("v1.2: notes", None, None).local, ".v1%2E2%3A notes");
300        assert_eq!(folder("Clients", Some("/"), None).role, Role::Other);
301        // Your names: a German server's bin, added in [words]; accents and capitals aside.
302        let config: crate::config::Config = toml::from_str("[words]\nlanguages = [\"en\"]\n[words.folders.trash]\nadd = [\"Papierkorb\"]\n").unwrap();
303        let yours = crate::words::Words::of(&config);
304        assert_eq!(role_in(&yours.folders, "INBOX.Papierkorb", None), Role::Trash);
305        assert_eq!(role_in(&crate::words::Words::builtin().folders, "INBOX.Papierkorb", None), Role::Other);
306        assert_eq!(role_in(&yours.folders, "ÉLÉMENTS ENVOYES", None), Role::Sent);
307        // Never the Maildir itself or its parent, never an empty level.
308        assert_eq!(folder("/", Some("/"), None).local, ".%00.%00");
309        assert_eq!(folder("INBOX.", Some("."), None).local, ".%00");
310        assert_eq!(folder("INBOX..", Some("."), None).local, ".%00.%00");
311        assert_eq!(folder("a//b", Some("/"), None).local, ".a.%00.b");
312        assert_eq!(folder("", None, None).local, ".%00");
313    }
314}