1use crate::config::{AccountConfig, Identity};
4use crate::mail_ops::{MailError, MailOps};
5use crate::message::clean;
6use crate::rules::apply::{ApplyError, apply, ensure_raw, move_target};
7use crate::rules::engine::{Context, Mode, Plan, PlannedAction, evaluate};
8use crate::rules::{Action, CompiledRule};
9use crate::store::{Message, Store, StoreError};
10use crate::trash::Trash;
11
12pub const RULE_NAME: &str = "cli";
14
15#[derive(Debug, thiserror::Error)]
16pub enum ActionError {
17 #[error(transparent)]
18 Apply(#[from] ApplyError),
19 #[error("{0} changed on the server since the last sync; run `postvak sync` first")]
20 FolderChanged(String),
21 #[error(transparent)]
22 Mail(#[from] MailError),
23 #[error("no message {folder}/{uid} in the local store; run `postvak sync` first")]
24 NotFound { folder: String, uid: u32 },
25 #[error(transparent)]
26 Store(#[from] StoreError),
27 #[error("{0} has not been synced yet; run `postvak sync` first")]
28 UnknownFolder(String),
29}
30
31pub type UidResult = (u32, Result<usize, ActionError>);
33
34fn select_synced(ops: &mut dyn MailOps, store: &Store, folder: &str) -> Result<(), ActionError> {
36 let stored = store
37 .folder(folder)?
38 .ok_or_else(|| ActionError::UnknownFolder(folder.to_string()))?;
39 if ops.select(folder)?.uidvalidity != stored.uidvalidity {
40 return Err(ActionError::FolderChanged(folder.to_string()));
41 }
42 Ok(())
43}
44
45#[allow(clippy::too_many_arguments)]
48pub fn run(
49 ops: &mut dyn MailOps,
50 store: &Store,
51 trash: &Trash,
52 folder: &str,
53 uids: &[u32],
54 action: &Action,
55 rule_name: &str,
56 now: i64,
57) -> Result<Vec<UidResult>, ActionError> {
58 select_synced(ops, store, folder)?;
59 let plan = Plan {
60 actions: vec![PlannedAction {
61 rule: rule_name.to_string(),
62 action: action.clone(),
63 }],
64 notify: false,
65 };
66 let mut results = Vec::new();
67 for &uid in uids {
68 let result = match store.message(folder, uid) {
69 Ok(Some(msg)) => apply(&plan, &msg, ops, store, trash, now).map_err(ActionError::from),
70 Ok(None) => Err(ActionError::NotFound {
71 folder: folder.to_string(),
72 uid,
73 }),
74 Err(e) => Err(e.into()),
75 };
76 results.push((uid, result));
77 }
78 Ok(results)
79}
80
81pub fn fetch_bodies(
84 ops: &mut dyn MailOps,
85 store: &Store,
86 folder: &str,
87) -> Result<usize, ActionError> {
88 let missing = store.messages_without_body(folder)?;
89 if missing.is_empty() {
90 return Ok(0);
91 }
92 select_synced(ops, store, folder)?;
93 let mut fetched = 0;
94 for msg in &missing {
95 match ensure_raw(msg, ops, store) {
96 Ok(_) => fetched += 1,
97 Err(e) => log::warn!("{}/{}: body fetch failed: {e}", msg.folder, msg.uid),
98 }
99 }
100 Ok(fetched)
101}
102
103pub fn fetch_body(
105 ops: &mut dyn MailOps,
106 store: &Store,
107 folder: &str,
108 uid: u32,
109) -> Result<(), ActionError> {
110 select_synced(ops, store, folder)?;
111 let msg = store
112 .message(folder, uid)?
113 .ok_or_else(|| ActionError::NotFound {
114 folder: folder.to_string(),
115 uid,
116 })?;
117 ensure_raw(&msg, ops, store)?;
118 Ok(())
119}
120
121const SERVER_HITS_PER_FOLDER: usize = 500;
123
124pub fn search_words(query: &str) -> Vec<String> {
126 query
127 .split_whitespace()
128 .map(|word| clean(word, false))
129 .filter(|word| !word.is_empty())
130 .collect()
131}
132
133pub fn search_server(
136 ops: &mut dyn MailOps,
137 store: &Store,
138 folder: &str,
139 words: &[String],
140) -> Result<Vec<u32>, ActionError> {
141 if words.is_empty() {
142 return Ok(Vec::new());
143 }
144 select_synced(ops, store, folder)?;
145 let mut hits = Vec::new();
146 for uid in ops
147 .search_text(words)?
148 .into_iter()
149 .rev()
150 .take(SERVER_HITS_PER_FOLDER)
151 {
152 if store.message(folder, uid)?.is_some() {
153 hits.push(uid);
154 }
155 }
156 Ok(hits)
157}
158
159pub fn with_server_hits(
161 store: &Store,
162 mut found: Vec<Message>,
163 hits: &[(String, u32)],
164 limit: u32,
165) -> Result<Vec<Message>, StoreError> {
166 for (folder, uid) in hits {
167 if found.iter().any(|m| m.folder == *folder && m.uid == *uid) {
168 continue;
169 }
170 if let Some(message) = store.message(folder, *uid)? {
171 found.push(message);
172 }
173 }
174 found.sort_by_key(|m| std::cmp::Reverse((m.internaldate, m.uid)));
175 found.truncate(limit as usize);
176 Ok(found)
177}
178
179pub fn planned_effect(store: &Store, msg: &Message, action: &Action) -> Result<String, StoreError> {
181 if *action != Action::Trash {
182 return Ok(format!("would {}", clean(&action.label(), false)));
183 }
184 let effect = match move_target(store, &msg.folder, action)? {
185 Some(trash) => format!("would move to {}", clean(&trash, false)),
186 None => "would delete (expunge, .eml backup kept)".to_string(),
187 };
188 Ok(effect)
189}
190
191#[derive(Debug, Clone)]
193pub struct Planned {
194 pub rule: String,
195 pub message: Message,
196 pub action: Action,
197}
198
199pub fn planned(
201 rules: &[CompiledRule],
202 store: &Store,
203 account: &AccountConfig,
204 identity: &Identity,
205 now: i64,
206) -> Result<Vec<Planned>, StoreError> {
207 let ctx = Context {
208 account: &account.name,
209 identity,
210 now,
211 mode: Mode::ApplyExisting,
212 notify_default: false,
213 };
214 let mut planned = Vec::new();
215 for folder in store.folders()? {
216 for message in store.messages_in_folder(&folder.name)? {
217 for PlannedAction { rule, action } in evaluate(rules, &message, &ctx).actions {
218 planned.push(Planned {
219 rule,
220 message: message.clone(),
221 action,
222 });
223 }
224 }
225 }
226 Ok(planned)
227}
228
229pub fn expunges(store: &Store, planned: &Planned) -> Result<bool, StoreError> {
231 Ok(match planned.action {
232 Action::Delete => true,
233 Action::Trash => move_target(store, &planned.message.folder, &Action::Trash)?.is_none(),
234 _ => false,
235 })
236}
237
238pub fn expunge_warning(
240 rules: &[CompiledRule],
241 store: &Store,
242 account: &AccountConfig,
243 identity: &Identity,
244 now: i64,
245) -> Result<Option<String>, StoreError> {
246 let mut count = 0;
247 for p in planned(rules, store, account, identity, now)? {
248 count += usize::from(expunges(store, &p)?);
249 }
250 Ok((count > 0).then(|| {
251 format!(
252 "{}: would permanently delete {count} message{} from the server; .eml backups kept locally for {} days",
253 account.name,
254 if count == 1 { "" } else { "s" },
255 account.trash_retention_days
256 )
257 }))
258}
259
260#[cfg(test)]
261mod tests {
262 use super::*;
263 use crate::mail_ops::RecordingOps;
264 use crate::store::{Folder, Message};
265
266 fn setup() -> (RecordingOps, Store, Trash, tempfile::TempDir) {
267 let dir = tempfile::tempdir().unwrap();
268 let mut ops = RecordingOps::default().with_folder("INBOX", None);
269 ops.add_mail(
270 "INBOX",
271 5,
272 100,
273 "Subject: hi\r\n\r\n",
274 Some("Subject: hi\r\n\r\nbody"),
275 );
276 let store = Store::open_in_memory().unwrap();
277 store
278 .upsert_folder(&Folder {
279 name: "INBOX".into(),
280 uidvalidity: 1,
281 last_uid: 5,
282 special_use: None,
283 delimiter: None,
284 })
285 .unwrap();
286 store
287 .insert_message(&Message {
288 folder: "INBOX".into(),
289 uid: 5,
290 thread_id: "t".into(),
291 subject: Some("hi".into()),
292 internaldate: 100,
293 headers: b"Subject: hi\r\n\r\n".to_vec(),
294 ..Default::default()
295 })
296 .unwrap();
297 (ops, store, Trash::new(dir.path().join("trash")), dir)
298 }
299
300 #[test]
301 fn planned_effect_names_a_delete_without_trash() {
302 let (_ops, store, _trash, _dir) = setup();
303 let msg = store.message("INBOX", 5).unwrap().unwrap();
304 assert_eq!(
305 planned_effect(&store, &msg, &Action::Trash).unwrap(),
306 "would delete (expunge, .eml backup kept)"
307 );
308 assert_eq!(
309 planned_effect(&store, &msg, &Action::Archive).unwrap(),
310 "would archive"
311 );
312 }
313
314 #[test]
315 fn expunges_only_deletes_and_trash_without_a_trash_folder() {
316 let (_ops, store, _trash, _dir) = setup();
317 let message = store.message("INBOX", 5).unwrap().unwrap();
318 let expunges_with = |action: Action| {
319 let planned = Planned {
320 rule: "r".into(),
321 message: message.clone(),
322 action,
323 };
324 expunges(&store, &planned).unwrap()
325 };
326 assert!(expunges_with(Action::Delete));
327 assert!(expunges_with(Action::Trash));
328 assert!(!expunges_with(Action::Archive));
329 assert!(!expunges_with(Action::Flag));
330 store
331 .upsert_folder(&Folder {
332 name: "Trash".into(),
333 uidvalidity: 1,
334 last_uid: 0,
335 special_use: Some("Trash".into()),
336 delimiter: None,
337 })
338 .unwrap();
339 assert!(!expunges_with(Action::Trash));
340 assert!(expunges_with(Action::Delete));
341 }
342
343 #[test]
344 fn expunge_warning_counts_what_the_rules_would_expunge() {
345 let (_ops, store, _trash, _dir) = setup();
346 let password = crate::config::PasswordSource::Keyring { keyring: true };
347 let account = AccountConfig::new("work", "h", "me@example.com", password);
348 let identity = account.identity().unwrap();
349 let warning = |actions: &str| {
350 let toml = format!(
351 "[[rules]]\nname = \"r\"\nmatch.subject = {{ contains = \"hi\" }}\nactions = [{actions}]\n"
352 );
353 let rules = crate::rules::compile(&crate::rules::parse(&toml).unwrap()).unwrap();
354 expunge_warning(&rules, &store, &account, &identity, 0).unwrap()
355 };
356 assert_eq!(
357 warning("\"delete\"").as_deref(),
358 Some(
359 "work: would permanently delete 1 message from the server; .eml backups kept locally for 30 days"
360 )
361 );
362 assert_eq!(warning("\"flag\""), None);
363 }
364
365 #[test]
366 fn run_applies_to_each_uid_and_reports_missing_ones() {
367 let (mut ops, store, trash, _dir) = setup();
368 let results = run(
369 &mut ops,
370 &store,
371 &trash,
372 "INBOX",
373 &[5, 99],
374 &Action::Flag,
375 "mcp:test-host",
376 500,
377 )
378 .unwrap();
379 assert!(matches!(results[0], (5, Ok(1))));
380 assert!(matches!(
381 results[1],
382 (99, Err(ActionError::NotFound { .. }))
383 ));
384 assert_eq!(ops.mail["INBOX"][0].flags, ["\\Flagged"]);
385 assert_eq!(store.log(10).unwrap()[0].rule_name, "mcp:test-host");
386 }
387
388 #[test]
389 fn run_refuses_a_folder_whose_uidvalidity_changed() {
390 let (mut ops, store, trash, _dir) = setup();
391 ops.uidvalidity.insert("INBOX".into(), 2);
392 let err = run(
393 &mut ops,
394 &store,
395 &trash,
396 "INBOX",
397 &[5],
398 &Action::Trash,
399 RULE_NAME,
400 500,
401 )
402 .unwrap_err();
403 assert!(matches!(err, ActionError::FolderChanged(_)));
404 assert_eq!(ops.mail["INBOX"].len(), 1);
405 assert_eq!(ops.calls, ["select INBOX"]);
406 }
407
408 #[test]
409 fn run_refuses_a_folder_that_was_never_synced() {
410 let (mut ops, store, trash, _dir) = setup();
411 let err = run(
412 &mut ops,
413 &store,
414 &trash,
415 "Receipts",
416 &[5],
417 &Action::Flag,
418 RULE_NAME,
419 500,
420 )
421 .unwrap_err();
422 assert!(matches!(err, ActionError::UnknownFolder(_)));
423 assert!(ops.calls.is_empty());
424 }
425
426 #[test]
427 fn fetch_bodies_indexes_only_missing_bodies() {
428 let (mut ops, store, _trash, _dir) = setup();
429 assert_eq!(fetch_bodies(&mut ops, &store, "INBOX").unwrap(), 1);
430 assert_eq!(store.search("body", None, 10).unwrap().len(), 1);
431 let calls = ops.calls.len();
432 assert_eq!(fetch_bodies(&mut ops, &store, "INBOX").unwrap(), 0);
433 assert_eq!(ops.calls.len(), calls, "nothing left to fetch");
434 }
435
436 #[test]
437 fn search_server_returns_the_hits_the_store_knows() {
438 let (mut ops, store, _trash, _dir) = setup();
439 let mut ops = {
440 ops.add_mail(
441 "INBOX",
442 9,
443 300,
444 "Subject: unsynced\r\n\r\n",
445 Some("Subject: unsynced\r\n\r\nbody"),
446 );
447 ops.with_folder("Archive", None)
448 };
449 ops.add_mail(
450 "Archive",
451 7,
452 200,
453 "Subject: old\r\n\r\n",
454 Some("Subject: old\r\n\r\nsome BODY text"),
455 );
456 ops.add_mail(
457 "Archive",
458 8,
459 250,
460 "Subject: other\r\n\r\n",
461 Some("Subject: other\r\n\r\nnothing"),
462 );
463 store
464 .upsert_folder(&Folder {
465 name: "Archive".into(),
466 uidvalidity: 1,
467 last_uid: 8,
468 special_use: None,
469 delimiter: None,
470 })
471 .unwrap();
472 for uid in [7, 8] {
473 store
474 .insert_message(&Message {
475 folder: "Archive".into(),
476 uid,
477 thread_id: format!("t{uid}"),
478 ..Default::default()
479 })
480 .unwrap();
481 }
482 let words = search_words("body \u{7}");
483 assert_eq!(words, ["body"]);
484 assert_eq!(
485 search_server(&mut ops, &store, "Archive", &words).unwrap(),
486 [7]
487 );
488 assert_eq!(
489 search_server(&mut ops, &store, "INBOX", &words).unwrap(),
490 [5],
491 "uid 9 is not synced yet"
492 );
493 ops.calls.clear();
494 assert!(
495 search_server(&mut ops, &store, "INBOX", &search_words(" "))
496 .unwrap()
497 .is_empty()
498 );
499 assert!(ops.calls.is_empty());
500 }
501
502 #[test]
503 fn with_server_hits_adds_the_known_ones_newest_first_once() {
504 let (_ops, store, _trash, _dir) = setup();
505 let older = Message {
506 folder: "INBOX".into(),
507 uid: 2,
508 thread_id: "t2".into(),
509 internaldate: 50,
510 ..Default::default()
511 };
512 store.insert_message(&older).unwrap();
513 let local = store.message("INBOX", 5).unwrap().into_iter().collect();
514 let hits = [
515 ("INBOX".to_string(), 2),
516 ("INBOX".to_string(), 5),
517 ("INBOX".to_string(), 77),
518 ];
519 let merged = with_server_hits(&store, local, &hits, 10).unwrap();
520 let uids: Vec<u32> = merged.iter().map(|m| m.uid).collect();
521 assert_eq!(uids, [5, 2]);
522 assert_eq!(
523 with_server_hits(&store, Vec::new(), &hits, 1)
524 .unwrap()
525 .len(),
526 1
527 );
528 }
529}