Skip to main content

postvak_core/rules/
apply.rs

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
39/// The message's body text, downloading the message once when the store lacks it.
40pub 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
51/// Fetches the whole message and stores it with its body text.
52fn 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
68/// A delete or user delete wins over everything else in the plan. Otherwise flag actions run first, then only the first
69/// move or archive. Returns how many actions ran; a flag already in the wanted state is skipped without a log row.
70pub 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    // Direct actions reach here from the daemon socket without passing the rules compiler, and a tag goes into STORE verbatim.
79    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(&current.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(&current, &target, ops, store)?;
132        executed += 1;
133    }
134    Ok(executed)
135}
136
137/// The first move or archive in the plan with its resolved target. Resolved before anything runs so a failure leaves no
138/// partial effects; a move into the message's own folder is dropped because the next sync would re-add and re-move it.
139fn 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
158/// Swaps the $Junk and $NotJunk keywords, then moves to the Junk folder or back to INBOX. A server that refuses
159/// keywords only loses the filter training, so the move still runs.
160fn 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(&current.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(&current, &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    // A user delete that falls back to expunging is logged as what happened, not as a move to Trash.
212    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(&current.folder, current.uid, &current.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
254/// The folder `action` moves a message in `folder` to; `None` when it stays or is expunged.
255pub 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
279/// The new uid is not known, so the local row goes and the next sync of the target folder adds it.
280fn 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    // The folder may exist on the server before our first sync of it; a real create failure surfaces in the move.
288    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(&current.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}