1use 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 All,
27 Other,
28}
29
30impl Role {
31 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 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62pub struct Folder {
63 pub name: String,
65 pub display: String,
67 pub role: Role,
68 pub local: String,
70 #[serde(default)]
72 pub special: bool,
73}
74
75pub fn saved_path(account: &str) -> std::path::PathBuf {
78 crate::config::state_dir().join("sync").join(format!("{account}.folders.toml"))
79}
80
81pub 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
91pub 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
97pub fn role_of(name: &str, special: Option<Role>) -> Role {
100 role_in(&crate::words::current().folders, name, special)
101}
102
103pub 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 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
123pub 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
131fn 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
146fn 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
176pub 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
203pub 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 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 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}