1use std::io;
2
3use crate::mail_ops::{MailError, MailOps};
4use crate::message::body_text;
5use crate::rules::Action;
6use crate::rules::engine::{Plan, PlannedAction};
7use crate::store::{Folder, LogEntry, Message, Store, StoreError, has_flag};
8use crate::trash::Trash;
9
10#[derive(Debug, thiserror::Error)]
11pub enum ApplyError {
12 #[error(transparent)]
13 Mail(#[from] MailError),
14 #[error(transparent)]
15 Store(#[from] StoreError),
16 #[error("could not write trash file: {0}")]
17 Trash(#[from] io::Error),
18 #[error("the server has no Archive folder")]
19 NoArchiveFolder,
20 #[error("the server has no Junk folder")]
21 NoJunkFolder,
22 #[error("tag {0}")]
23 InvalidTag(String),
24 #[error("message {folder}/{uid} could not be fetched for backup")]
25 RawUnavailable { folder: String, uid: u32 },
26}
27
28pub fn ensure_raw(
29 msg: &Message,
30 ops: &mut dyn MailOps,
31 store: &Store,
32) -> Result<Vec<u8>, ApplyError> {
33 if let Some(raw) = store.raw(&msg.folder, msg.uid)? {
34 return Ok(raw);
35 }
36 download(msg, ops, store).map(|(raw, _)| raw)
37}
38
39pub fn ensure_body_text(
41 msg: &Message,
42 ops: &mut dyn MailOps,
43 store: &Store,
44) -> Result<String, ApplyError> {
45 if let Some(raw) = store.raw(&msg.folder, msg.uid)? {
46 return Ok(body_text(&raw));
47 }
48 download(msg, ops, store).map(|(_, text)| text)
49}
50
51fn download(
53 msg: &Message,
54 ops: &mut dyn MailOps,
55 store: &Store,
56) -> Result<(Vec<u8>, String), ApplyError> {
57 let raw = ops
58 .fetch_raw(msg.uid)?
59 .ok_or_else(|| ApplyError::RawUnavailable {
60 folder: msg.folder.clone(),
61 uid: msg.uid,
62 })?;
63 let text = body_text(&raw);
64 store.set_raw(&msg.folder, msg.uid, &raw, &text)?;
65 Ok((raw, text))
66}
67
68pub fn apply(
71 plan: &Plan,
72 msg: &Message,
73 ops: &mut dyn MailOps,
74 store: &Store,
75 trash: &Trash,
76 now: i64,
77) -> Result<usize, ApplyError> {
78 for planned in &plan.actions {
80 if let Action::Tag(keyword) = &planned.action {
81 crate::rules::check_keyword(keyword).map_err(ApplyError::InvalidTag)?;
82 }
83 }
84 if let Some(planned) = plan
85 .actions
86 .iter()
87 .find(|planned| matches!(planned.action, Action::Delete | Action::Trash))
88 {
89 let trash_folder = match planned.action {
90 Action::Trash => move_target(store, &msg.folder, &Action::Trash)?,
91 _ => None,
92 };
93 match trash_folder {
94 Some(folder) => {
95 store.log_action(&log_entry(planned, msg, now))?;
96 move_to(msg, &folder, ops, store)?;
97 }
98 None => delete(planned, msg, ops, store, trash, now)?,
99 }
100 return Ok(1);
101 }
102 if let Some(planned) = plan
103 .actions
104 .iter()
105 .find(|planned| matches!(planned.action, Action::Junk | Action::NotJunk))
106 {
107 junk(planned, msg, ops, store, now)?;
108 return Ok(1);
109 }
110 let first_move = first_move(plan, msg, store)?;
111 let mut current = msg.clone();
112 let mut executed = 0;
113 for planned in &plan.actions {
114 let (flag, wanted) = match planned.action {
115 Action::Flag => ("\\Flagged", true),
116 Action::MarkRead => ("\\Seen", true),
117 Action::MarkUnread => ("\\Seen", false),
118 Action::Unflag => ("\\Flagged", false),
119 Action::Tag(ref keyword) => (keyword.as_str(), true),
120 _ => continue,
121 };
122 if has_flag(¤t.flags, flag) == wanted {
123 continue;
124 }
125 store.log_action(&log_entry(planned, msg, now))?;
126 set_flag(&mut current, flag, wanted, ops, store)?;
127 executed += 1;
128 }
129 if let Some((planned, target)) = first_move {
130 store.log_action(&log_entry(planned, msg, now))?;
131 move_to(¤t, &target, ops, store)?;
132 executed += 1;
133 }
134 Ok(executed)
135}
136
137fn first_move<'a>(
140 plan: &'a Plan,
141 msg: &Message,
142 store: &Store,
143) -> Result<Option<(&'a PlannedAction, String)>, ApplyError> {
144 let Some(planned) = plan
145 .actions
146 .iter()
147 .find(|planned| matches!(planned.action, Action::Move(_) | Action::Archive))
148 else {
149 return Ok(None);
150 };
151 let target = match &planned.action {
152 Action::Move(target) => target.clone(),
153 _ => archive_folder(store)?,
154 };
155 Ok((target != msg.folder).then_some((planned, target)))
156}
157
158fn junk(
161 planned: &PlannedAction,
162 msg: &Message,
163 ops: &mut dyn MailOps,
164 store: &Store,
165 now: i64,
166) -> Result<(), ApplyError> {
167 let (target, keyword, opposite) = match planned.action {
168 Action::Junk => (junk_folder(store)?, "$Junk", "$NotJunk"),
169 _ => ("INBOX".to_string(), "$NotJunk", "$Junk"),
170 };
171 store.log_action(&log_entry(planned, msg, now))?;
172 let mut current = msg.clone();
173 for (flag, wanted) in [(opposite, false), (keyword, true)] {
174 if has_flag(¤t.flags, flag) != wanted
175 && let Err(e) = set_flag(&mut current, flag, wanted, ops, store)
176 {
177 log::warn!("{}/{}: could not set {flag}: {e}", msg.folder, msg.uid);
178 }
179 }
180 if target != msg.folder {
181 move_to(¤t, &target, ops, store)?;
182 }
183 Ok(())
184}
185
186fn log_entry(planned: &PlannedAction, msg: &Message, now: i64) -> LogEntry {
187 LogEntry {
188 id: 0,
189 at: now,
190 rule_name: planned.rule.clone(),
191 folder: msg.folder.clone(),
192 uid: msg.uid,
193 message_id: msg.message_id.clone(),
194 subject: msg.subject.clone(),
195 action: planned.action.label(),
196 trash_file: None,
197 }
198}
199
200fn delete(
201 planned: &PlannedAction,
202 msg: &Message,
203 ops: &mut dyn MailOps,
204 store: &Store,
205 trash: &Trash,
206 now: i64,
207) -> Result<(), ApplyError> {
208 let raw = ensure_raw(msg, ops, store)?;
209 let path = trash.save(&msg.folder, msg.uid, &raw, now)?;
210 let mut entry = log_entry(planned, msg, now);
211 entry.action = Action::Delete.label();
213 entry.trash_file = Some(path.to_string_lossy().into_owned());
214 store.log_action(&entry)?;
215 ops.add_flags(msg.uid, &["\\Deleted"])?;
216 ops.expunge(msg.uid)?;
217 store.remove_message(&msg.folder, msg.uid)?;
218 Ok(())
219}
220
221fn set_flag(
222 current: &mut Message,
223 flag: &str,
224 wanted: bool,
225 ops: &mut dyn MailOps,
226 store: &Store,
227) -> Result<(), ApplyError> {
228 if wanted {
229 ops.add_flags(current.uid, &[flag])?;
230 } else {
231 ops.remove_flags(current.uid, &[flag])?;
232 }
233 let mut flags: Vec<&str> = current
234 .flags
235 .split(' ')
236 .filter(|f| !f.is_empty() && *f != flag)
237 .collect();
238 if wanted {
239 flags.push(flag);
240 }
241 current.flags = flags.join(" ");
242 store.update_flags(¤t.folder, current.uid, ¤t.flags)?;
243 Ok(())
244}
245
246fn special_folder(store: &Store, role: &str) -> Result<Option<String>, StoreError> {
247 Ok(store
248 .folders()?
249 .into_iter()
250 .find(|folder| folder.special_use.as_deref() == Some(role))
251 .map(|folder| folder.name))
252}
253
254pub fn move_target(
256 store: &Store,
257 folder: &str,
258 action: &Action,
259) -> Result<Option<String>, StoreError> {
260 let target = match action {
261 Action::Archive => special_folder(store, "Archive")?,
262 Action::Move(target) => Some(target.clone()),
263 Action::Trash => special_folder(store, "Trash")?,
264 Action::Junk => special_folder(store, "Junk")?,
265 Action::NotJunk => Some("INBOX".to_string()),
266 _ => None,
267 };
268 Ok(target.filter(|target| target != folder))
269}
270
271fn archive_folder(store: &Store) -> Result<String, ApplyError> {
272 special_folder(store, "Archive")?.ok_or(ApplyError::NoArchiveFolder)
273}
274
275fn junk_folder(store: &Store) -> Result<String, ApplyError> {
276 special_folder(store, "Junk")?.ok_or(ApplyError::NoJunkFolder)
277}
278
279fn move_to(
281 current: &Message,
282 target: &str,
283 ops: &mut dyn MailOps,
284 store: &Store,
285) -> Result<(), ApplyError> {
286 let known = store.folder(target)?.is_some();
287 if !known && let Err(e) = ops.create_folder(target) {
289 log::debug!("{target}: create failed ({e}), trying the move anyway");
290 }
291 ops.move_message(current.uid, target)?;
292 if !known {
293 store.upsert_folder(&Folder {
294 name: target.to_string(),
295 uidvalidity: 0,
296 last_uid: 0,
297 special_use: None,
298 delimiter: None,
299 })?;
300 }
301 store.remove_message(¤t.folder, current.uid)?;
302 Ok(())
303}
304
305#[cfg(test)]
306mod tests {
307 use super::*;
308 use crate::mail_ops::RecordingOps;
309
310 fn setup() -> (RecordingOps, Store, Trash, tempfile::TempDir, Message) {
311 let dir = tempfile::tempdir().unwrap();
312 let mut ops = RecordingOps::default()
313 .with_folder("INBOX", None)
314 .with_folder("Archive", Some("Archive"));
315 ops.add_mail(
316 "INBOX",
317 5,
318 100,
319 "Subject: hi\r\n\r\n",
320 Some("Subject: hi\r\n\r\nbody text"),
321 );
322 ops.select("INBOX").unwrap();
323 let store = Store::open_in_memory().unwrap();
324 store
325 .upsert_folder(&Folder {
326 name: "INBOX".into(),
327 uidvalidity: 1,
328 last_uid: 5,
329 special_use: None,
330 delimiter: None,
331 })
332 .unwrap();
333 store
334 .upsert_folder(&Folder {
335 name: "Archive".into(),
336 uidvalidity: 1,
337 last_uid: 0,
338 special_use: Some("Archive".into()),
339 delimiter: None,
340 })
341 .unwrap();
342 let msg = Message {
343 folder: "INBOX".into(),
344 uid: 5,
345 message_id: Some("m5@x".into()),
346 from_addr: Some("a@x".into()),
347 thread_id: "m5@x".into(),
348 subject: Some("hi".into()),
349 internaldate: 100,
350 headers: b"Subject: hi\r\n\r\n".to_vec(),
351 ..Default::default()
352 };
353 store.insert_message(&msg).unwrap();
354 let trash = Trash::new(dir.path().join("trash"));
355 (ops, store, trash, dir, msg)
356 }
357
358 fn plan(actions: Vec<Action>) -> Plan {
359 Plan {
360 actions: actions
361 .into_iter()
362 .map(|action| PlannedAction {
363 rule: "r".into(),
364 action,
365 })
366 .collect(),
367 notify: false,
368 }
369 }
370
371 #[test]
372 fn delete_backs_up_before_expunge_and_logs() {
373 let (mut ops, store, trash, _dir, msg) = setup();
374 apply(
375 &plan(vec![Action::Delete]),
376 &msg,
377 &mut ops,
378 &store,
379 &trash,
380 500,
381 )
382 .unwrap();
383 let calls = ops.calls.clone();
384 let fetch = calls
385 .iter()
386 .position(|c| c.starts_with("fetch_raw"))
387 .unwrap();
388 let expunge = calls.iter().position(|c| c.starts_with("expunge")).unwrap();
389 assert!(fetch < expunge);
390 let saved = trash.list().unwrap();
391 assert_eq!(saved.len(), 1);
392 assert_eq!(
393 std::fs::read(&saved[0].path).unwrap(),
394 b"Subject: hi\r\n\r\nbody text"
395 );
396 assert!(ops.mail["INBOX"].is_empty());
397 assert_eq!(store.message("INBOX", 5).unwrap(), None);
398 let log = store.log(10).unwrap();
399 assert_eq!(log[0].action, "delete");
400 assert!(
401 log[0]
402 .trash_file
403 .as_deref()
404 .unwrap()
405 .ends_with("-INBOX-5.eml")
406 );
407 }
408
409 #[test]
410 fn delete_is_refused_when_raw_cannot_be_fetched() {
411 let (mut ops, store, trash, _dir, msg) = setup();
412 ops.raw.clear();
413 let err = apply(
414 &plan(vec![Action::Delete]),
415 &msg,
416 &mut ops,
417 &store,
418 &trash,
419 500,
420 )
421 .unwrap_err();
422 assert!(matches!(err, ApplyError::RawUnavailable { .. }));
423 assert!(!ops.calls.iter().any(|c| c.starts_with("expunge")));
424 assert_eq!(ops.mail["INBOX"].len(), 1);
425 }
426
427 #[test]
428 fn delete_wins_over_other_actions() {
429 let (mut ops, store, trash, _dir, msg) = setup();
430 apply(
431 &plan(vec![
432 Action::Move("Archive".into()),
433 Action::MarkRead,
434 Action::Delete,
435 ]),
436 &msg,
437 &mut ops,
438 &store,
439 &trash,
440 500,
441 )
442 .unwrap();
443 assert_eq!(trash.list().unwrap().len(), 1);
444 assert!(ops.mail["INBOX"].is_empty());
445 assert!(ops.mail["Archive"].is_empty());
446 assert!(!ops.calls.iter().any(|c| c.starts_with("move")));
447 assert!(
448 !ops.calls
449 .iter()
450 .any(|c| c.starts_with("add_flags") && c.contains("\\Seen"))
451 );
452 assert_eq!(store.log(10).unwrap().len(), 1);
453 }
454
455 #[test]
456 fn only_first_move_runs_and_flags_precede_it() {
457 let (mut ops, store, trash, _dir, msg) = setup();
458 apply(
459 &plan(vec![
460 Action::Move("Archive".into()),
461 Action::MarkRead,
462 Action::Move("Other".into()),
463 ]),
464 &msg,
465 &mut ops,
466 &store,
467 &trash,
468 500,
469 )
470 .unwrap();
471 let flag = ops
472 .calls
473 .iter()
474 .position(|c| c == "add_flags INBOX 5 \\Seen")
475 .unwrap();
476 let moved = ops
477 .calls
478 .iter()
479 .position(|c| c == "move INBOX 5 -> Archive")
480 .unwrap();
481 assert!(flag < moved);
482 assert!(!ops.calls.iter().any(|c| c.contains("Other")));
483 let actions: Vec<String> = store
484 .log(10)
485 .unwrap()
486 .into_iter()
487 .rev()
488 .map(|entry| entry.action)
489 .collect();
490 assert_eq!(actions, ["mark_read", "move:Archive"]);
491 }
492
493 #[test]
494 fn move_creates_missing_folder_and_drops_row_for_resync() {
495 let (mut ops, store, trash, _dir, msg) = setup();
496 apply(
497 &plan(vec![Action::Move("Lists/GitHub".into()), Action::MarkRead]),
498 &msg,
499 &mut ops,
500 &store,
501 &trash,
502 500,
503 )
504 .unwrap();
505 assert!(ops.calls.iter().any(|c| c == "create_folder Lists/GitHub"));
506 assert!(store.folder("Lists/GitHub").unwrap().is_some());
507 assert_eq!(store.message("INBOX", 5).unwrap(), None);
508 assert!(
509 store.messages_in_folder("Lists/GitHub").unwrap().is_empty(),
510 "the next sync of the target folder adds the row"
511 );
512 assert!(
513 ops.mail["Lists/GitHub"][0]
514 .flags
515 .contains(&"\\Seen".to_string()),
516 "mark_read runs before the move, so the server copy carries it"
517 );
518 }
519
520 #[test]
521 fn move_into_folder_the_store_does_not_know_yet() {
522 let (mut ops, store, trash, _dir, msg) = setup();
523 ops = ops.with_folder("Lists", None);
524 let executed = apply(
525 &plan(vec![Action::Move("Lists".into())]),
526 &msg,
527 &mut ops,
528 &store,
529 &trash,
530 500,
531 )
532 .unwrap();
533 assert_eq!(executed, 1);
534 assert_eq!(ops.mail["Lists"].len(), 1);
535 assert!(ops.mail["INBOX"].is_empty());
536 assert!(store.folder("Lists").unwrap().is_some());
537 }
538
539 #[test]
540 fn move_to_own_folder_is_skipped() {
541 let (mut ops, store, trash, _dir, msg) = setup();
542 apply(
543 &plan(vec![Action::Move("INBOX".into())]),
544 &msg,
545 &mut ops,
546 &store,
547 &trash,
548 500,
549 )
550 .unwrap();
551 assert!(!ops.calls.iter().any(|c| c.starts_with("move")));
552 assert!(store.message("INBOX", 5).unwrap().is_some());
553 assert!(store.log(10).unwrap().is_empty());
554 }
555
556 #[test]
557 fn archive_without_folder_changes_nothing() {
558 let (mut ops, store, trash, _dir, msg) = setup();
559 store
560 .upsert_folder(&Folder {
561 name: "Archive".into(),
562 uidvalidity: 1,
563 last_uid: 0,
564 special_use: None,
565 delimiter: None,
566 })
567 .unwrap();
568 let result = apply(
569 &plan(vec![Action::MarkRead, Action::Archive]),
570 &msg,
571 &mut ops,
572 &store,
573 &trash,
574 500,
575 );
576 assert!(matches!(result, Err(ApplyError::NoArchiveFolder)));
577 assert!(!ops.calls.iter().any(|c| c.starts_with("add_flags")));
578 assert!(store.log(10).unwrap().is_empty());
579 }
580
581 fn with_junk(ops: RecordingOps, store: &Store) -> RecordingOps {
582 store
583 .upsert_folder(&Folder {
584 name: "Spam".into(),
585 uidvalidity: 1,
586 last_uid: 0,
587 special_use: Some("Junk".into()),
588 delimiter: None,
589 })
590 .unwrap();
591 ops.with_folder("Spam", Some("Junk"))
592 }
593
594 #[test]
595 fn junk_tags_and_moves_to_the_junk_folder() {
596 let (ops, store, trash, _dir, mut msg) = setup();
597 let mut ops = with_junk(ops, &store);
598 ops.select("INBOX").unwrap();
599 msg.flags = "$NotJunk".into();
600 store.update_flags("INBOX", 5, "$NotJunk").unwrap();
601 let ran = apply(
602 &plan(vec![Action::Junk]),
603 &msg,
604 &mut ops,
605 &store,
606 &trash,
607 500,
608 )
609 .unwrap();
610 assert_eq!(ran, 1);
611 let calls: Vec<&str> = ops
612 .calls
613 .iter()
614 .map(String::as_str)
615 .filter(|c| !c.starts_with("select"))
616 .collect();
617 assert_eq!(
618 calls,
619 [
620 "remove_flags INBOX 5 $NotJunk",
621 "add_flags INBOX 5 $Junk",
622 "move INBOX 5 -> Spam"
623 ]
624 );
625 assert!(store.message("INBOX", 5).unwrap().is_none());
626 assert_eq!(store.log(10).unwrap()[0].action, "junk");
627 }
628
629 #[test]
630 fn not_junk_tags_and_moves_back_to_the_inbox() {
631 let (ops, store, trash, _dir, _msg) = setup();
632 let mut ops = with_junk(ops, &store);
633 ops.add_mail("Spam", 7, 100, "Subject: hi\r\n\r\n", None);
634 ops.select("Spam").unwrap();
635 let msg = Message {
636 folder: "Spam".into(),
637 uid: 7,
638 thread_id: "t".into(),
639 flags: "$Junk".into(),
640 ..Default::default()
641 };
642 store.insert_message(&msg).unwrap();
643 apply(
644 &plan(vec![Action::NotJunk]),
645 &msg,
646 &mut ops,
647 &store,
648 &trash,
649 500,
650 )
651 .unwrap();
652 let calls: Vec<&str> = ops
653 .calls
654 .iter()
655 .map(String::as_str)
656 .filter(|c| !c.starts_with("select"))
657 .collect();
658 assert_eq!(
659 calls,
660 [
661 "remove_flags Spam 7 $Junk",
662 "add_flags Spam 7 $NotJunk",
663 "move Spam 7 -> INBOX"
664 ]
665 );
666 }
667
668 #[test]
669 fn junk_still_moves_when_the_server_refuses_keywords() {
670 let (ops, store, trash, _dir, msg) = setup();
671 let mut ops = with_junk(ops, &store);
672 ops.select("INBOX").unwrap();
673 ops.fail_next = Some(MailError::Protocol("no keywords".into()));
674 apply(
675 &plan(vec![Action::Junk]),
676 &msg,
677 &mut ops,
678 &store,
679 &trash,
680 500,
681 )
682 .unwrap();
683 assert!(ops.calls.iter().any(|c| c == "move INBOX 5 -> Spam"));
684 }
685
686 #[test]
687 fn junk_without_a_junk_folder_changes_nothing() {
688 let (mut ops, store, trash, _dir, msg) = setup();
689 let result = apply(
690 &plan(vec![Action::Junk]),
691 &msg,
692 &mut ops,
693 &store,
694 &trash,
695 500,
696 );
697 assert!(matches!(result, Err(ApplyError::NoJunkFolder)));
698 assert!(
699 !ops.calls
700 .iter()
701 .any(|c| c.starts_with("add_flags") || c.starts_with("move"))
702 );
703 assert!(store.log(10).unwrap().is_empty());
704 }
705
706 #[test]
707 fn delete_refused_when_trash_write_fails() {
708 let (mut ops, store, _trash, dir, msg) = setup();
709 let blocked = dir.path().join("not-a-dir");
710 std::fs::write(&blocked, b"").unwrap();
711 let err = apply(
712 &plan(vec![Action::Delete]),
713 &msg,
714 &mut ops,
715 &store,
716 &Trash::new(blocked),
717 500,
718 )
719 .unwrap_err();
720 assert!(matches!(err, ApplyError::Trash(_)));
721 assert!(
722 !ops.calls
723 .iter()
724 .any(|c| c.starts_with("add_flags") || c.starts_with("expunge"))
725 );
726 assert_eq!(ops.mail["INBOX"].len(), 1);
727 assert!(store.message("INBOX", 5).unwrap().is_some());
728 }
729
730 #[test]
731 fn archive_uses_special_use_folder_or_errors() {
732 let (mut ops, store, trash, _dir, msg) = setup();
733 apply(
734 &plan(vec![Action::Archive]),
735 &msg,
736 &mut ops,
737 &store,
738 &trash,
739 500,
740 )
741 .unwrap();
742 assert_eq!(ops.mail["Archive"].len(), 1);
743 let (mut ops2, store2, trash2, _dir2, msg2) = setup();
744 store2
745 .upsert_folder(&Folder {
746 name: "Archive".into(),
747 uidvalidity: 1,
748 last_uid: 0,
749 special_use: None,
750 delimiter: None,
751 })
752 .unwrap();
753 assert!(matches!(
754 apply(
755 &plan(vec![Action::Archive]),
756 &msg2,
757 &mut ops2,
758 &store2,
759 &trash2,
760 500
761 ),
762 Err(ApplyError::NoArchiveFolder)
763 ));
764 }
765
766 #[test]
767 fn flag_and_mark_read_update_server_and_store() {
768 let (mut ops, store, trash, _dir, msg) = setup();
769 apply(
770 &plan(vec![Action::Flag, Action::MarkRead]),
771 &msg,
772 &mut ops,
773 &store,
774 &trash,
775 500,
776 )
777 .unwrap();
778 let m = store.message("INBOX", 5).unwrap().unwrap();
779 assert_eq!(m.flags, "\\Flagged \\Seen");
780 assert_eq!(store.log(10).unwrap().len(), 2);
781 }
782
783 #[test]
784 fn tag_adds_the_keyword_once() {
785 let (mut ops, store, trash, _dir, mut msg) = setup();
786 let tag = plan(vec![Action::Tag("$label1".into())]);
787 assert_eq!(apply(&tag, &msg, &mut ops, &store, &trash, 500).unwrap(), 1);
788 assert!(ops.calls.iter().any(|c| c == "add_flags INBOX 5 $label1"));
789 assert_eq!(store.message("INBOX", 5).unwrap().unwrap().flags, "$label1");
790 assert_eq!(store.log(10).unwrap()[0].action, "tag:$label1");
791 msg.flags = "$label1".into();
792 assert_eq!(apply(&tag, &msg, &mut ops, &store, &trash, 500).unwrap(), 0);
793 }
794
795 #[test]
796 fn a_tag_that_is_not_an_imap_keyword_is_refused_before_the_server() {
797 let (mut ops, store, trash, _dir, msg) = setup();
798 let bad = plan(vec![Action::Tag("x) \\Deleted".into())]);
799 assert!(matches!(
800 apply(&bad, &msg, &mut ops, &store, &trash, 500),
801 Err(ApplyError::InvalidTag(_))
802 ));
803 assert!(!ops.calls.iter().any(|c| c.starts_with("add_flags")));
804 assert!(store.log(10).unwrap().is_empty());
805 }
806
807 fn with_trash_folder(ops: RecordingOps, store: &Store) -> RecordingOps {
808 store
809 .upsert_folder(&Folder {
810 name: "Trash".into(),
811 uidvalidity: 1,
812 last_uid: 0,
813 special_use: Some("Trash".into()),
814 delimiter: None,
815 })
816 .unwrap();
817 ops.with_folder("Trash", Some("Trash"))
818 }
819
820 #[test]
821 fn mark_unread_and_unflag_remove_flags_once() {
822 let (mut ops, store, trash, _dir, mut msg) = setup();
823 ops.add_flags(5, &["\\Seen", "\\Flagged"]).unwrap();
824 store.update_flags("INBOX", 5, "\\Seen \\Flagged").unwrap();
825 msg.flags = "\\Seen \\Flagged".into();
826 let unset = plan(vec![Action::MarkUnread, Action::Unflag]);
827 assert_eq!(
828 apply(&unset, &msg, &mut ops, &store, &trash, 500).unwrap(),
829 2
830 );
831 assert!(ops.mail["INBOX"][0].flags.is_empty());
832 let stored = store.message("INBOX", 5).unwrap().unwrap();
833 assert_eq!(stored.flags, "");
834 assert_eq!(
835 apply(&unset, &stored, &mut ops, &store, &trash, 500).unwrap(),
836 0
837 );
838 let actions: Vec<String> = store
839 .log(10)
840 .unwrap()
841 .into_iter()
842 .map(|e| e.action)
843 .collect();
844 assert_eq!(actions, ["unflag", "mark_unread"]);
845 }
846
847 #[test]
848 fn user_delete_moves_to_the_trash_folder() {
849 let (ops, store, trash, _dir, msg) = setup();
850 let mut ops = with_trash_folder(ops, &store);
851 assert_eq!(
852 apply(
853 &plan(vec![Action::Trash]),
854 &msg,
855 &mut ops,
856 &store,
857 &trash,
858 500
859 )
860 .unwrap(),
861 1
862 );
863 assert_eq!(ops.mail["Trash"].len(), 1);
864 assert!(ops.mail["INBOX"].is_empty());
865 assert_eq!(store.message("INBOX", 5).unwrap(), None);
866 assert!(!ops.calls.iter().any(|c| c.starts_with("expunge")));
867 assert!(trash.list().unwrap().is_empty());
868 assert_eq!(store.log(10).unwrap()[0].action, "trash");
869 }
870
871 #[test]
872 fn rule_delete_expunges_even_with_a_trash_folder() {
873 let (ops, store, trash, _dir, msg) = setup();
874 let mut ops = with_trash_folder(ops, &store);
875 apply(
876 &plan(vec![Action::Delete]),
877 &msg,
878 &mut ops,
879 &store,
880 &trash,
881 500,
882 )
883 .unwrap();
884 assert!(ops.mail["Trash"].is_empty());
885 assert!(ops.mail["INBOX"].is_empty());
886 assert!(!ops.calls.iter().any(|c| c.starts_with("move")));
887 assert!(ops.calls.iter().any(|c| c.starts_with("expunge")));
888 assert_eq!(trash.list().unwrap().len(), 1);
889 assert_eq!(store.log(10).unwrap()[0].action, "delete");
890 }
891
892 #[test]
893 fn user_delete_without_trash_folder_expunges_with_backup() {
894 let (mut ops, store, trash, _dir, msg) = setup();
895 apply(
896 &plan(vec![Action::Trash]),
897 &msg,
898 &mut ops,
899 &store,
900 &trash,
901 500,
902 )
903 .unwrap();
904 assert!(ops.mail["INBOX"].is_empty());
905 assert_eq!(trash.list().unwrap().len(), 1);
906 let log = store.log(10).unwrap();
907 assert!(log[0].trash_file.is_some());
908 assert_eq!(
909 log[0].action, "delete",
910 "the log says the message was expunged"
911 );
912 }
913
914 #[test]
915 fn user_delete_inside_the_trash_folder_expunges_with_backup() {
916 let (mut ops, store, trash, _dir, msg) = setup();
917 store
918 .upsert_folder(&Folder {
919 name: "INBOX".into(),
920 uidvalidity: 1,
921 last_uid: 5,
922 special_use: Some("Trash".into()),
923 delimiter: None,
924 })
925 .unwrap();
926 apply(
927 &plan(vec![Action::Trash]),
928 &msg,
929 &mut ops,
930 &store,
931 &trash,
932 500,
933 )
934 .unwrap();
935 assert!(!ops.calls.iter().any(|c| c.starts_with("move")));
936 assert!(ops.mail["INBOX"].is_empty());
937 assert_eq!(trash.list().unwrap().len(), 1);
938 }
939}